105 lines
2.9 KiB
TypeScript
105 lines
2.9 KiB
TypeScript
interface SendFunctionArgs {
|
|
/**
|
|
* @default "message"
|
|
*/
|
|
event?: string;
|
|
data: string;
|
|
}
|
|
|
|
interface SendFunction {
|
|
(args: SendFunctionArgs): void;
|
|
}
|
|
|
|
interface CleanupFunction {
|
|
(): void;
|
|
}
|
|
|
|
interface AbortFunction {
|
|
(): void;
|
|
}
|
|
|
|
interface InitFunction {
|
|
(send: SendFunction, abort: AbortFunction): CleanupFunction;
|
|
}
|
|
|
|
/**
|
|
* A response helper to use Server Sent Events server-side
|
|
* @param signal The AbortSignal used to close the stream
|
|
* @param init The function that will be called to initialize the stream, here you can subscribe to your events
|
|
* @returns A Response object that can be returned from a loader
|
|
*/
|
|
export function eventStream(signal: AbortSignal, init: InitFunction, options: ResponseInit = {}) {
|
|
let dispose = () => undefined;
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
const encoder = new TextEncoder();
|
|
let closed = false;
|
|
let cleanup: CleanupFunction | undefined;
|
|
let cleanupComplete = false;
|
|
|
|
function runCleanup() {
|
|
if (!cleanup || cleanupComplete) return;
|
|
cleanupComplete = true;
|
|
cleanup();
|
|
}
|
|
|
|
dispose = () => {
|
|
if (closed) return;
|
|
// Mark the stream closed before invoking user cleanup. A cleanup can
|
|
// synchronously publish one final event, which must become a no-op.
|
|
closed = true;
|
|
signal.removeEventListener("abort", close);
|
|
runCleanup();
|
|
};
|
|
|
|
function send({ event = "message", data }: SendFunctionArgs) {
|
|
if (closed) return;
|
|
controller.enqueue(encoder.encode(`event: ${event}\n`));
|
|
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
|
|
}
|
|
|
|
function close() {
|
|
if (closed) return;
|
|
dispose();
|
|
controller.close();
|
|
}
|
|
|
|
signal.addEventListener("abort", close);
|
|
cleanup = init(send, close);
|
|
|
|
// `init` may synchronously close the stream before returning cleanup.
|
|
if (closed) runCleanup();
|
|
|
|
if (signal.aborted) return close();
|
|
},
|
|
cancel() {
|
|
// Cancellation already closes the Web Stream controller. Only dispose
|
|
// subscriptions/timers here; calling controller.close() would throw.
|
|
dispose();
|
|
},
|
|
});
|
|
|
|
const headers = new Headers(options.headers);
|
|
|
|
if (headers.has("Content-Type")) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn("Overriding Content-Type header to `text/event-stream`");
|
|
}
|
|
|
|
if (headers.has("Cache-Control")) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn("Overriding Cache-Control header to `no-cache`");
|
|
}
|
|
|
|
if (headers.has("Connection")) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn("Overriding Connection header to `keep-alive`");
|
|
}
|
|
|
|
headers.set("Content-Type", "text/event-stream");
|
|
headers.set("Cache-Control", "no-cache");
|
|
headers.set("Connection", "keep-alive");
|
|
|
|
return new Response(stream, { headers });
|
|
}
|