unjs / h3

⚡️ Minimal H(TTP) framework built for high performance and portability
https://h3.unjs.io/
MIT License
3.56k stars 209 forks source link

Support server-sent-events #477

Closed pi0 closed 7 months ago

pi0 commented 1 year ago

Server sent events (https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) are an alternative to WS using long pooling connections in order to handle events.

joshmossas commented 10 months ago

Hello I'd be interested in helping out with this effort. Has any thought been put into what the utilities for server sent events might look like?

Ideally there would be a simple way of detecting clients closing the connection so that any ongoing processes and listeners can be cleaned up. Right now my manual implementation makes use of event.node.req.on('close', ...) to detect client disconnects and event.node.req.on('end', ...) to detect when the server has closed the connection. Although it looks like H3 is trying to move away from reliance on the node apis so I'm unsure what the runtime agnostic implementation is supposed to look like, or does calling event.node.whatever just work in other environments due to mocking with unenv?

As far as utilities themselves maybe they could look something like this?

// sets necessary headers
// creates a stream to send back to the client
// set event._handled to `true`
const session = createSseSession(event);

// push an unamed event to the client
session.push({
  data: 'hello world',
});

// push a named event with an id
session.push({
  id: '1',
  event: 'some-event',
  data: 'hello world'
});

// listen for client disconnects
session.on('close', () => {
  // clean up
});

// listen for server ending the connection
session.on('end', () => {
 // clean up
});

// close the connection and end the writable stream
session.end();

// make use of the existing sendStream utility for streaming responses to clients
await sendStream(event, session.stream);
})

This proposal only adds one new h3 utility, createSseSession() which accept an H3Event as its input. This utility then initializes a class that holds all of the necessary state needed to send events, close the connection, and listen for disconnects. The only other question is how to handle the Last-Event-ID header. I'm of the mind that this should be handled manually with h3's existing utilities, but it also could theoretically be automatically handled by createdSseSession() (i.e. if the header exists make that the session ID, if it doesn't exist generate a new id using randomUuid() from uncrypto)

Other Notes

There are some alternative names that could we could explore as well.

I've also explored other implementations which could be used for API design inspiration. I'll include some of them below:

Oak (Deno Middleware Library)

router.get("/sse", async (ctx: Context) => {
  const target = ctx.sendEvents();
  // send unamed event
  target.dispathMessage({ hello: "world" });
  // send named event
  target.dispatchEvent(new ServerSentEvent("event-name", { hello: "world" });
}

Better SSE

app.get("/sse", async (req, res) => {
  const session = await createSession(req, res);
  // send unamed event
  session.push("Hello world");
  // send named event with id
  session.push("Hello world", "event-name", "event-id");
  session.isConnected // true | false
});
joshmossas commented 10 months ago

For anyone that's looking to get this up and running right now, my manual implementation currently looks something like this:

router.get(
    "/sse", 
    defineEventHandler(async (event) => {
        setHeaders(event, {
            "Transfer-Encoding": "chunked",
            "Content-Type": "text/event-stream",
            Connection: "keep-alive",
            "Cache-Control": "no-cache",
        });
        setResponseStatus(event, 200);
        let sessionId = getHeader(event, "Last-Event-ID");
        if (!sessionId) {
            sessionId = randomUUID();
        }
        const { readable, writable } = new TransformStream();
        const writer = writable.getWriter();
        const encoder = new TextEncoder();
        let count = 0;
        const interval = setInterval(async () => {
            count++;
            await writer.write(
                encoder.encode(
                    `id: ${sessionId}\ndata: ${JSON.stringify({
                        count,
                        message: "Hello world!",
                    })}\n\n`,
                ),
            );
        }, 1000);
        async function cleanup() {
            clearInterval(interval);
            if (!writer.closed) {
                await writer.close();
            }
            if (!event.node.res.closed) {
                event.node.res.end();
            }
        }
        event.node.req
            .on("close", async () => {
                console.log("CLOSED");
                await cleanup();
            })
            .on("end", async () => {
                console.log("ENDED");
                await cleanup();
            });
        event._handled = true;
        await sendStream(event, readable);
}));
joshmossas commented 7 months ago

In case anyone is looking for a solution right now I've published h3-sse an npm package that has server sent event utilities for H3.

It has all the same stuff in #586 but as a separate dependency until that PR gets merged or replaced by something else.

pi0 commented 7 months ago

Landed via #386 as experimental feature and available in nightly channel thanks so much @joshmossas for helping on this! (some notes: https://github.com/unjs/h3/pull/586#issuecomment-1962736961)