UNPKG

better-sse

Version:

Dead simple, dependency-less, spec-compliant server-sent events implementation written in TypeScript.

771 lines (753 loc) 27.7 kB
let node_events = require("node:events"); let node_stream = require("node:stream"); let node_http = require("node:http"); let node_http2 = require("node:http2"); let node_timers = require("node:timers"); //#region src/adapters/Connection.ts const connectionConstants = Object.freeze({ REQUEST_METHOD: "GET", REQUEST_HOST: "localhost", RESPONSE_CODE: 200, RESPONSE_HEADERS: Object.freeze({ "Content-Type": "text/event-stream", "Cache-Control": "private, no-cache, no-store, no-transform, must-revalidate, max-age=0", Connection: "keep-alive", Pragma: "no-cache", "X-Accel-Buffering": "no" }) }); /** * Represents the full request and response of an underlying network connection, * abstracting away the differences between the Node HTTP/1, HTTP/2, Fetch and * any other APIs. * * You can implement your own custom `Connection` subclass to make Better SSE * compatible with any framework. */ var Connection = class { /** * Useful constants that you may use when implementing your own custom connection. */ static constants = connectionConstants; /** * Utility method to consistently merge headers from an object into a `Headers` object. * * For each entry in `from`: * - If the value is a `string`, it will replace the target header. * - If the value is an `Array`, it will replace the target header and then append each item. * - If the value is `undefined`, it will delete the target header. */ static applyHeaders(from, to) { const fromMap = from instanceof Headers ? Object.fromEntries(from) : from; for (const [key, value] of Object.entries(fromMap)) if (Array.isArray(value)) { to.delete(key); for (const item of value) to.append(key, item); } else if (value === void 0) to.delete(key); else to.set(key, value); } }; //#endregion //#region src/adapters/FetchConnection.ts var FetchConnection = class FetchConnection extends Connection { static encoder = new TextEncoder(); writer; url; request; response; constructor(request, response, options = {}) { super(); this.url = new URL(request.url); this.request = request; const { readable, writable } = new TransformStream(); this.writer = writable.getWriter(); this.response = new Response(readable, { status: options.statusCode ?? response?.status ?? Connection.constants.RESPONSE_CODE, headers: Connection.constants.RESPONSE_HEADERS }); if (response) Connection.applyHeaders(response.headers, this.response.headers); if (options.headers) Connection.applyHeaders(options.headers, this.response.headers); } sendHead = () => {}; sendChunk = (chunk) => { const encoded = FetchConnection.encoder.encode(chunk); this.writer.write(encoded); }; cleanup = () => { this.writer.close(); }; }; //#endregion //#region src/adapters/NodeHttp1Connection.ts var NodeHttp1Connection = class extends Connection { controller; url; request; response; constructor(req, res, options = {}) { super(); this.req = req; this.res = res; req.socket?.setNoDelay?.(true); res.socket?.setNoDelay?.(true); this.url = new URL(req.url ?? "/", `http://${req.headers.host ?? Connection.constants.REQUEST_HOST}`); this.controller = new AbortController(); req.once("close", this.onClose); res.once("close", this.onClose); this.request = new Request(this.url, { method: req.method ?? Connection.constants.REQUEST_METHOD, signal: this.controller.signal }); Connection.applyHeaders(req.headers, this.request.headers); this.response = new Response(null, { status: options.statusCode ?? res.statusCode ?? Connection.constants.RESPONSE_CODE, headers: Connection.constants.RESPONSE_HEADERS }); if (res) Connection.applyHeaders(res.getHeaders(), this.response.headers); if (options.headers) Connection.applyHeaders(options.headers, this.response.headers); } onClose = () => { this.controller.abort(); }; sendHead = () => { this.res.writeHead(this.response.status, Object.fromEntries(this.response.headers)); }; sendChunk = (chunk) => { this.res.write(chunk); }; cleanup = () => { this.req.removeListener("close", this.onClose); this.res.removeListener("close", this.onClose); }; }; //#endregion //#region src/adapters/NodeHttp2CompatConnection.ts var NodeHttp2CompatConnection = class extends Connection { controller; url; request; response; constructor(req, res, options = {}) { super(); this.req = req; this.res = res; req.socket?.setNoDelay?.(true); res.socket?.setNoDelay?.(true); this.url = new URL(req.url ?? "/", `http://${req.headers.host ?? Connection.constants.REQUEST_HOST}`); this.controller = new AbortController(); req.once("close", this.onClose); res.once("close", this.onClose); this.request = new Request(this.url, { method: req.method ?? Connection.constants.REQUEST_METHOD, signal: this.controller.signal }); const allowedHeaders = { ...req.headers }; for (const header of Object.keys(allowedHeaders)) if (header.startsWith(":")) delete allowedHeaders[header]; Connection.applyHeaders(allowedHeaders, this.request.headers); this.response = new Response(null, { status: options.statusCode ?? res.statusCode ?? Connection.constants.RESPONSE_CODE, headers: Connection.constants.RESPONSE_HEADERS }); if (res) Connection.applyHeaders(res.getHeaders(), this.response.headers); if (options.headers) Connection.applyHeaders(options.headers, this.response.headers); } onClose = () => { this.controller.abort(); }; sendHead = () => { this.res.writeHead(this.response.status, Object.fromEntries(this.response.headers)); }; sendChunk = (chunk) => { this.res.write(chunk); }; cleanup = () => { this.req.removeListener("close", this.onClose); this.res.removeListener("close", this.onClose); }; }; //#endregion //#region src/utils/SseError.ts var SseError = class extends Error { constructor(message) { super(message); this.message = `better-sse: ${message}`; } }; //#endregion //#region src/utils/TypedEmitter.ts /** * Wraps the EventEmitter class to add types that map event names * to types of arguments in the event handler callback. */ var TypedEmitter = class extends node_events.EventEmitter { constructor(...args) { super(...args); this.setMaxListeners(Number.POSITIVE_INFINITY); } addListener(event, listener) { return super.addListener(event, listener); } prependListener(event, listener) { return super.prependListener(event, listener); } prependOnceListener(event, listener) { return super.prependOnceListener(event, listener); } on(event, listener) { return super.on(event, listener); } once(event, listener) { return super.once(event, listener); } emit(event, ...args) { return super.emit(event, ...args); } off(event, listener) { return super.off(event, listener); } removeListener(event, listener) { return super.removeListener(event, listener); } }; //#endregion //#region src/Channel.ts /** * A `Channel` is used to broadcast events to many sessions at once. * * It extends from the {@link https://nodejs.org/api/events.html#events_class_eventemitter | EventEmitter} class. * * You may use the second generic argument `SessionState` to enforce that only sessions with the same state type may be registered with this channel. */ var Channel = class extends TypedEmitter { /** * Custom state for this channel. * * Use this object to safely store information related to the channel. */ state; sessions = /* @__PURE__ */ new Set(); constructor(options = {}) { super(); this.state = options.state ?? {}; } /** * List of the currently active sessions registered with this channel. */ get activeSessions() { return Array.from(this.sessions); } /** * Number of sessions registered with this channel. */ get sessionCount() { return this.sessions.size; } /** * Register a session so that it can start receiving events from this channel. * * If the session was already registered to begin with this method does nothing. * * @param session - Session to register. */ register(session) { if (this.sessions.has(session)) return this; if (!session.isConnected) throw new SseError("Cannot register a non-active session."); session.once("disconnected", () => { this.emit("session-disconnected", session); this.deregister(session); }); this.sessions.add(session); this.emit("session-registered", session); return this; } /** * Deregister a session so that it no longer receives events from this channel. * * If the session was not registered to begin with this method does nothing. * * @param session - Session to deregister. */ deregister(session) { if (!this.sessions.has(session)) return this; this.sessions.delete(session); this.emit("session-deregistered", session); return this; } /** * Broadcast an event to every active session registered with this channel. * * Under the hood this calls the `push` method on every active session. * * If no event name is given, the event name is set to `"message"`. * * Note that the broadcasted event will have the same ID across all receiving sessions instead of generating a unique ID for each. * * @param data - Data to write. * @param eventName - Event name to write. */ broadcast = (data, eventName = "message", options = {}) => { const eventId = options.eventId ?? crypto.randomUUID(); const sessions = options.filter ? this.activeSessions.filter(options.filter) : this.sessions; for (const session of sessions) session.push(data, eventName, eventId); this.emit("broadcast", data, eventName, eventId); return this; }; }; //#endregion //#region src/createChannel.ts const createChannel = (...args) => new Channel(...args); //#endregion //#region src/utils/createPushFromIterable.ts const createPushFromIterable = (push) => async (iterable, options = {}) => { const { eventName = "iteration" } = options; for await (const data of iterable) push(data, eventName); }; //#endregion //#region src/utils/createPushFromStream.ts const createPushFromStream = (push) => async (stream, options = {}) => { const { eventName = "stream" } = options; if (stream instanceof node_stream.Readable) return await new Promise((resolve, reject) => { stream.on("data", (chunk) => { let data; if (Buffer.isBuffer(chunk)) data = chunk.toString(); else data = chunk; push(data, eventName); }); stream.once("end", () => resolve(true)); stream.once("close", () => resolve(true)); stream.once("error", (err) => reject(err)); }); for await (const chunk of stream) if (Buffer.isBuffer(chunk)) push(chunk.toString(), eventName); else push(chunk, eventName); return true; }; //#endregion //#region src/utils/sanitize.ts const newlineVariantsRegex = /(\r\n|\r|\n)/g; const newlineTrailingRegex = /\n+$/g; const sanitize = (text) => { let sanitized = text; sanitized = sanitized.replace(newlineVariantsRegex, "\n"); sanitized = sanitized.replace(newlineTrailingRegex, ""); return sanitized; }; //#endregion //#region src/utils/serialize.ts const serialize = (data) => JSON.stringify(data); //#endregion //#region src/EventBuffer.ts /** * An `EventBuffer` allows you to write raw spec-compliant SSE fields into a text buffer that can be sent directly over the wire. */ var EventBuffer = class { buffer = ""; serialize; sanitize; constructor(options = {}) { this.serialize = options.serializer ?? serialize; this.sanitize = options.sanitizer ?? sanitize; } /** * Write a line with a field key and value appended with a newline character. */ writeField = (name, value) => { const sanitized = this.sanitize(value); this.buffer += name + ":" + sanitized + "\n"; return this; }; /** * Write an event name field (also referred to as the event "type" in the specification). * * @param type - Event name/type. */ event(type) { this.writeField("event", type); return this; } /** * Write arbitrary data into a data field. * * Data is serialized to a string using the given `serializer` function option or JSON stringification by default. * * @param data - Data to serialize and write. */ data = (data) => { const serialized = this.serialize(data); this.writeField("data", serialized); return this; }; /** * Write an event ID field. * * Defaults to an empty string if no argument is given. * * @param id - Identification string to write. */ id = (id = "") => { this.writeField("id", id); return this; }; /** * Write a retry field that suggests a reconnection time with the given milliseconds. * * @param time - Time in milliseconds to retry. */ retry = (time) => { const stringifed = time.toString(); this.writeField("retry", stringifed); return this; }; /** * Write a comment (an ignored field). * * This will not fire an event but is often used to keep the connection alive. * * @param text - Text of the comment. Otherwise writes an empty field value. */ comment = (text = "") => { this.writeField("", text); return this; }; /** * Indicate that the event has finished being created by writing an additional newline character. */ dispatch = () => { this.buffer += "\n"; return this; }; /** * Create, write and dispatch an event with the given data all at once. * * This is equivalent to calling the methods `event`, `id`, `data` and `dispatch` in that order. * * If no event name is given, the event name is set to `"message"`. * * If no event ID is given, the event ID is set to a randomly generated UUIDv4. * * @param data - Data to write. * @param eventName - Event name to write. * @param eventId - Event ID to write. */ push = (data, eventName = "message", eventId = crypto.randomUUID()) => { this.event(eventName).id(eventId).data(data).dispatch(); return this; }; /** * Pipe readable stream data as a series of events into the buffer. * * This uses the `push` method under the hood. * * If no event name is given in the `options` object, the event name is set to `"stream"`. * * @param stream - Readable stream to consume data from. * @param options - Event name to use for each event created. * * @returns A promise that resolves with `true` or rejects based on the success of the stream write finishing. */ stream = createPushFromStream(this.push); /** * Iterate over an iterable and write yielded values as events into the buffer. * * This uses the `push` method under the hood. * * If no event name is given in the `options` object, the event name is set to `"iteration"`. * * @param iterable - Iterable to consume data from. * * @returns A promise that resolves once all data has been successfully yielded from the iterable. */ iterate = createPushFromIterable(this.push); /** * Clear the contents of the buffer. */ clear = () => { this.buffer = ""; return this; }; /** * Get a copy of the buffer contents. */ read = () => this.buffer; }; //#endregion //#region src/createEventBuffer.ts const createEventBuffer = (...args) => new EventBuffer(...args); //#endregion //#region src/Session.ts /** * A `Session` represents an open connection between the server and a client. * * It extends from the {@link https://nodejs.org/api/events.html#events_class_eventemitter | EventEmitter} class. * * It emits the `connected` event after it has connected and sent the response head to the client. * It emits the `disconnected` event after the connection has been closed. * * When using the Fetch API, the session is considered connected only once the `ReadableStream` contained in the body * of the `Response` returned by `getResponse` has began being consumed. * * When using the Node HTTP APIs, the session will send the response with status code, headers and other preamble data ahead of time, * allowing the session to connect and start pushing events immediately. As such, keep in mind that attempting * to write additional headers after the session has been created will result in an error being thrown. * * @param req - The Node HTTP/1 {@link https://nodejs.org/api/http.html#http_class_http_incomingmessage | ServerResponse}, HTTP/2 {@link https://nodejs.org/api/http2.html#class-http2http2serverrequest | Http2ServerRequest} or the Fetch API {@link https://developer.mozilla.org/en-US/docs/Web/API/Request | Request} object. * @param res - The Node HTTP {@link https://nodejs.org/api/http.html#http_class_http_serverresponse | IncomingMessage}, HTTP/2 {@link https://nodejs.org/api/http2.html#class-http2http2serverresponse | Http2ServerResponse} or the Fetch API {@link https://developer.mozilla.org/en-US/docs/Web/API/Response | Response} object. Optional if using the Fetch API. * @param options - Optional additional configuration for the session. */ var Session = class extends TypedEmitter { /** * The last event ID sent to the client. * * This is initialized to the last event ID given by the user, and otherwise is equal to the last number given to the `.id` method. * * For security reasons, keep in mind that the client can provide *any* initial ID here. Use the `trustClientEventId` constructor option to ignore the client-given initial ID. * * @readonly */ lastId = ""; /** * Indicates whether the session and underlying connection is open or not. * * @readonly */ isConnected = false; /** * Custom state for this session. * * Use this object to safely store information related to the session and user. * * Use [module augmentation and declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) * to safely add new properties to the `DefaultSessionState` interface. */ state; buffer; connection; sanitize; serialize; initialRetry; keepAliveInterval; keepAliveTimer; constructor(req, res, options) { super(); let givenOptions = options ?? {}; if (req instanceof Request) { let givenRes = null; if (res) if (res instanceof Response) givenRes = res; else { if (options) throw new SseError("When providing a Fetch Request object but no Response object, you may pass options as the second OR third argument to the session constructor, but not to both."); givenOptions = res ?? {}; } this.connection = new FetchConnection(req, givenRes, givenOptions); } else if (req instanceof node_http.IncomingMessage) if (res instanceof node_http.ServerResponse) this.connection = new NodeHttp1Connection(req, res, givenOptions); else throw new SseError("When providing a Node IncomingMessage object, a corresponding ServerResponse object must also be provided."); else if (req instanceof node_http2.Http2ServerRequest) if (res instanceof node_http2.Http2ServerResponse) this.connection = new NodeHttp2CompatConnection(req, res, givenOptions); else throw new SseError("When providing a Node HTTP2ServerRequest object, a corresponding HTTP2ServerResponse object must also be provided."); else if (req instanceof Connection) { this.connection = req; if (options) throw new SseError("When providing a Connection object, you may pass options as the second argument, but not to the third argument."); givenOptions = res ?? {}; } else throw new SseError("Malformed request or response objects given to session constructor. Must be one of IncomingMessage/ServerResponse from the Node HTTP/1 API, HTTP2ServerRequest/HTTP2ServerResponse from the Node HTTP/2 Compatibility API, or Request/Response from the Fetch API."); if (givenOptions.trustClientEventId !== false) this.lastId = this.connection.request.headers.get("last-event-id") ?? this.connection.url.searchParams.get("lastEventId") ?? this.connection.url.searchParams.get("evs_last_event_id") ?? ""; this.state = givenOptions.state ?? {}; this.initialRetry = givenOptions.retry === null ? null : givenOptions.retry ?? 2e3; this.keepAliveInterval = givenOptions.keepAlive === null ? null : givenOptions.keepAlive ?? 1e4; this.serialize = givenOptions.serializer ?? serialize; this.sanitize = givenOptions.sanitizer ?? sanitize; this.buffer = new EventBuffer({ serializer: this.serialize, sanitizer: this.sanitize }); this.connection.request.signal.addEventListener("abort", this.onDisconnected); (0, node_timers.setImmediate)(this.initialize); } initialize = () => { this.connection.sendHead(); if (this.connection.url.searchParams.has("padding")) this.buffer.comment(" ".repeat(2049)).dispatch(); if (this.connection.url.searchParams.has("evs_preamble")) this.buffer.comment(" ".repeat(2056)).dispatch(); if (this.initialRetry !== null) this.buffer.retry(this.initialRetry).dispatch(); this.flush(); if (this.keepAliveInterval !== null) this.keepAliveTimer = setInterval(this.keepAlive, this.keepAliveInterval); this.isConnected = true; this.emit("connected"); }; onDisconnected = () => { this.connection.request.signal.removeEventListener("abort", this.onDisconnected); this.connection.cleanup(); if (this.keepAliveTimer) clearInterval(this.keepAliveTimer); this.isConnected = false; this.emit("disconnected"); }; /** * Write an empty comment and flush it to the client. */ keepAlive = () => { this.buffer.comment().dispatch(); this.flush(); }; /** * Flush the contents of the internal buffer to the client and clear the buffer. */ flush = () => { const contents = this.buffer.read(); this.buffer.clear(); this.connection.sendChunk(contents); }; /** * Get a Request object representing the request of the underlying connection this session manages. * * When using the Fetch API, this will be the original Request object passed to the session constructor. * * When using the Node HTTP APIs, this will be a new Request object with status code and headers copied from the original request. * When the originally given request or response is closed, the abort signal attached to this Request will be triggered. */ getRequest = () => this.connection.request; /** * Get a Response object representing the response of the underlying connection this session manages. * * When using the Fetch API, this will be a new Response object with status code and headers copied from the original response if given. * Its body will be a ReadableStream that should begin being consumed for the session to consider itself connected. * * When using the Node HTTP APIs, this will be a new Response object with status code and headers copied from the original response. * Its body will be `null`, as data is instead written to the stream of the originally given response object. */ getResponse = () => this.connection.response; /** * Push an event to the client. * * If no event name is given, the event name is set to `"message"`. * * If no event ID is given, the event ID (and thus the `lastId` property) is set to a randomly generated UUIDv4. * * If the session has disconnected, an `SseError` will be thrown. * * Emits the `push` event with the given data, event name and event ID in that order. * * @param data - Data to write. * @param eventName - Event name to write. * @param eventId - Event ID to write. */ push = (data, eventName = "message", eventId = crypto.randomUUID()) => { if (!this.isConnected) throw new SseError("Cannot push data to a non-active session. Ensure the session is connected before attempting to push events. If using the Fetch API, the response stream must begin being consumed before the session is considered connected."); this.buffer.push(data, eventName, eventId); this.flush(); this.lastId = eventId; this.emit("push", data, eventName, eventId); return this; }; /** * Pipe readable stream data as a series of events to the client. * * This uses the `push` method under the hood. * * If no event name is given in the `options` object, the event name is set to `"stream"`. * * @param stream - Readable stream to consume data from. * @param options - Options to alter how the stream is flushed to the client. * * @returns A promise that resolves with `true` or rejects based on the success of the stream write finishing. */ stream = createPushFromStream(this.push); /** * Iterate over an iterable and send yielded values as events to the client. * * This uses the `push` method under the hood. * * If no event name is given in the `options` object, the event name is set to `"iteration"`. * * @param iterable - Iterable to consume data from. * * @returns A promise that resolves once all data has been successfully yielded from the iterable. */ iterate = createPushFromIterable(this.push); /** * Batch and send multiple events at once. * * If given an `EventBuffer` instance, its contents will be sent to the client. * * If given a callback, it will be passed an instance of `EventBuffer` which uses the same serializer and sanitizer as the session. * Once its execution completes - or once it resolves if it returns a promise - the contents of the passed `EventBuffer` will be sent to the client. * * @param batcher - Event buffer to get contents from, or callback that takes an event buffer to write to. * * @returns A promise that resolves once all data from the event buffer has been successfully sent to the client. * * @see EventBuffer */ batch = async (batcher) => { if (batcher instanceof EventBuffer) this.connection.sendChunk(batcher.read()); else { const buffer = new EventBuffer({ serializer: this.serialize, sanitizer: this.sanitize }); await batcher(buffer); this.connection.sendChunk(buffer.read()); } }; }; //#endregion //#region src/createResponse.ts function createResponse(request, response, options, callback) { const args = [ request, response, options, callback ]; let givenCallback; for (let index = args.length - 1; index >= 0; --index) { const arg = args.pop(); if (arg) { givenCallback = arg; break; } } if (typeof givenCallback !== "function") throw new SseError("Last argument given to createResponse must be a callback function."); /** * TypeScript compares every type in the union with every type in every overload, * guaranteeing an incompatibility even if each of the passed combinations of arguments * actually does have at least one matching counterpart. * * As such, we must decide between this small ignore-line or having the * `createResponse` and `Session#constructor` functions not be overloaded at all. * * @see https://github.com/microsoft/TypeScript/issues/14107 */ const session = new Session(...args); session.once("connected", () => { givenCallback(session); }); return session.getResponse(); } //#endregion //#region src/createSession.ts function createSession(req, res, options) { return new Promise((resolve) => { /** * TypeScript compares every type in the union with every type in every overload, * guaranteeing an incompatibility even if each of the passed combinations of arguments * actually does have at least one matching counterpart. * * As such, we must decide between this small ignore-line or having the * `createSession` and `Session#constructor` functions not be overloaded at all. * * @see https://github.com/microsoft/TypeScript/issues/14107 */ const session = new Session(req, res, options); if (req instanceof Request) resolve(session); else session.once("connected", () => { resolve(session); }); }); } //#endregion exports.Channel = Channel; exports.Connection = Connection; exports.EventBuffer = EventBuffer; exports.FetchConnection = FetchConnection; exports.NodeHttp1Connection = NodeHttp1Connection; exports.NodeHttp2CompatConnection = NodeHttp2CompatConnection; exports.Session = Session; exports.SseError = SseError; exports.createChannel = createChannel; exports.createEventBuffer = createEventBuffer; exports.createResponse = createResponse; exports.createSession = createSession;