better-sse
Version:
Dead simple, dependency-less, spec-compliant server-sent events implementation written in TypeScript.
771 lines (753 loc) • 27.7 kB
JavaScript
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;