Example of a passenger app for launching Streamlit apps

I worked a bit on a way to launch a Streamlit application as a Passenger app. A bit in the spirit Cunningham’s Law, I figured I would post what I got to work if anyone else in the future was interested, or at least maybe they would post a better way to do this and correct me, but I didn’t want to waste the work.

We don’t have a use case for this application at the moment, so I don’t want to post it to the appverse having only tested it personally without deploying it for anyone, so if someone else is motivated to use this as a starting point and publish it, be my guest.

Implementation

The main thing that makes Streamlit different from a Flask based Passenger app is that Streamlit supports only listening on a TCP/UDS socket or (recently) ASGI protocols, none of these options are available to the PUN, I tried a2wgsi which is supposed to allow you to use ASGI applications (streamlit) with WSGI web servers (Passenger) but I had difficulting using it with the websockets that Streamlit needed.

What I did to get it working was, spawn a NodeJS shim to do the following:

a) spawn the Streamlit process to listen on a unix socket

b) Act as a HTTP proxy to the Streamlit app’s unix socket.

const { createProxyServer } = require('httpxy');
const http = require('http');
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');

async function startApp() {
    // Define a unique path for the socket file
    const username = os.userInfo().username
    const socketPath = path.join("/tmp", `streamlit_chatbot_${username}.sock`);

    // Cleanup function to remove the socket file on exit
    const cleanup = () => {
        if (fs.existsSync(socketPath)) {
            try { fs.unlinkSync(socketPath); } catch (e) { }
        }
    };

    process.on('exit', cleanup);
    process.on('SIGINT', () => { process.exit(); });
    process.on('SIGTERM', () => { process.exit(); });

    // Spawn streamlit app
    const streamlit = spawn('bin/python', [
        '-m', 'streamlit', 'run', '<path/to/streamlit_app>.py',
        '--server.address', `unix://${socketPath}`,
        '--server.headless', 'true',
        '--browser.gatherUsageStats', 'false',
        '--server.baseUrlPath', process.env.PASSENGER_BASE_URI || '/'
    ]);
    // Awful hard coded sleep to wait for the streamlit app to start
    const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
    await sleep(2000);
    // Logging
    streamlit.stdout.on('data', (data) => console.log(`[Streamlit] ${data}`));
    streamlit.stderr.on('data', (data) => console.error(`[Streamlit Error] ${data}`));

    const proxy = createProxyServer({
        target: {
            socketPath: socketPath
        },
        ws: true
    });

    const server = http.createServer(async (req, res) => {
        try {
            await proxy.web(req, res, {
                target: socketPath,
            });
        }
        catch (err) {
            console.error('[Proxy Error]', err);
            res.statusCode = 502;
            res.end('Bad Gateway: Streamlit is starting or socket is unavailable.');
        }

    });

    // Websocket handling
    server.on("upgrade", (req, socket, head) => {
        proxy.ws(req, socket, { target: { socketPath: socketPath } }, head);
    });

    server.listen(0, () => {
        console.log(`Proxying to Streamlit via Unix Socket: ${socketPath}`);
    });
}

startApp().catch(console.error);

Other than this file is a standard Passenger app, you would have app.js and node_modules to start the shim, and the dependencies/code for your Streamlit app in the same directory. But technically the application is a NodeJS passenger app not a Python passenger app so you would not have a passenger_wsgi.py file.

Hope somebody finds this useful.

1 Like