Closed pi0 closed 7 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)
There are some alternative names that could we could explore as well.
session.push()
could be something else like session.publish()
, session.write()
, session.broadcast()
, or session.sendEvent()
session.on('close')
could be something else like session.on('disconnect')
. Another alternative would be we could provide a session.isConnected
property that changes when the client disconnects.I've also explored other implementations which could be used for API design inspiration. I'll include some of them below:
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" });
}
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
});
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);
}));
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.
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)
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.