205 lines
7.7 KiB
TypeScript
205 lines
7.7 KiB
TypeScript
import type { Context, Handler } from "hono";
|
|
import { SSE_CODES, SseError } from "../error-codes/error-codes.js";
|
|
import { formatHeartbeatFrame, formatSseFrame } from "../formatter/format.js";
|
|
import { getSseInstruments } from "../otel/instruments.js";
|
|
import type { SseService } from "../services/service.js";
|
|
import type { OverflowPolicy } from "../types/types.js";
|
|
|
|
/**
|
|
* Apply the configured overflow policy to the supplied backlog when
|
|
* it has just exceeded the high-water mark. Returns `true` when the
|
|
* incoming (newest) frame was discarded so the caller can short-circuit
|
|
* the post-write bookkeeping. Exported so unit tests can pin every
|
|
* branch without orchestrating a Hono request lifecycle.
|
|
*/
|
|
export function applyOverflowPolicy(
|
|
backlog: Uint8Array[],
|
|
queueHighWaterMark: number,
|
|
policy: OverflowPolicy
|
|
): boolean {
|
|
if (backlog.length <= queueHighWaterMark) return false;
|
|
const instruments = getSseInstruments();
|
|
if (policy === "drop-newest") {
|
|
backlog.pop();
|
|
instruments.queueOverflowTotal.add(1, {
|
|
policy: "drop-newest",
|
|
code: SSE_CODES.QUEUE_OVERFLOW,
|
|
});
|
|
return true;
|
|
}
|
|
backlog.shift();
|
|
instruments.queueOverflowTotal.add(1, {
|
|
policy: "drop-oldest",
|
|
code: SSE_CODES.QUEUE_OVERFLOW,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Options for `createSseRoute`. The handler reads `:channel` from the
|
|
* request path; `resolvePrincipalId` lets consumers wire any auth
|
|
* stack (e.g. `@suaveplan/api-plugin-auth`'s `c.get("user")?.id`)
|
|
* without this package depending on it.
|
|
*/
|
|
export interface CreateSseRouteOptions {
|
|
readonly heartbeatIntervalMs: number;
|
|
readonly queueHighWaterMark: number;
|
|
readonly overflowPolicy: OverflowPolicy;
|
|
readonly resolvePrincipalId?: (c: Context) => string | undefined;
|
|
/**
|
|
* When `true`, the handler raises `SseError(SSE_UNAUTHENTICATED)`
|
|
* if `resolvePrincipalId` returns `undefined`. The default
|
|
* (`false`) allows anonymous connections so the route may be
|
|
* composed with any external auth middleware.
|
|
*/
|
|
readonly requirePrincipal?: boolean;
|
|
readonly setIntervalImpl?: (fn: () => void, ms: number) => unknown;
|
|
readonly clearIntervalImpl?: (handle: unknown) => void;
|
|
}
|
|
|
|
/**
|
|
* Hono handler factory wrapping the SSE service. Returns a Response
|
|
* with a `ReadableStream<Uint8Array>` body so any standards-compliant
|
|
* runtime (Bun, Node 18+, Cloudflare Workers, Deno) streams without
|
|
* an adapter shim.
|
|
*
|
|
* Behavior:
|
|
* - `:channel` path parameter is required; missing throws
|
|
* `SseError(SSE_CHANNEL_REQUIRED)`.
|
|
* - `Last-Event-ID` request header (browser-replay convention) is
|
|
* forwarded to `service.subscribe` for ring-buffer replay.
|
|
* - The request's `AbortSignal` is threaded through to the service
|
|
* so client disconnect unsubscribes deterministically.
|
|
* - Every `heartbeatIntervalMs` writes a `: heartbeat\n\n` comment
|
|
* frame; absent traffic, this keeps proxies + load balancers from
|
|
* silently closing the connection.
|
|
* - Backpressure: when `controller.desiredSize === 0` and the
|
|
* in-memory backlog exceeds `queueHighWaterMark`, applies
|
|
* `overflowPolicy` and increments `sse.queue_overflow_total`.
|
|
*/
|
|
export function createSseRoute(
|
|
service: SseService,
|
|
options: CreateSseRouteOptions
|
|
): Handler {
|
|
const setIntervalFn =
|
|
options.setIntervalImpl ?? ((fn, ms) => setInterval(fn, ms) as unknown);
|
|
const clearIntervalFn =
|
|
options.clearIntervalImpl ??
|
|
((h) => clearInterval(h as ReturnType<typeof setInterval>));
|
|
|
|
return async function sseHandler(c) {
|
|
const channel = c.req.param("channel");
|
|
if (channel === undefined || channel === "") {
|
|
throw new SseError(
|
|
SSE_CODES.CHANNEL_REQUIRED,
|
|
"SSE handler requires a :channel path parameter"
|
|
);
|
|
}
|
|
const lastEventId =
|
|
c.req.header("last-event-id") ?? c.req.header("Last-Event-ID");
|
|
const principalId = options.resolvePrincipalId?.(c);
|
|
if (options.requirePrincipal === true && principalId === undefined) {
|
|
throw new SseError(
|
|
SSE_CODES.UNAUTHENTICATED,
|
|
"SSE handler requires an authenticated principal",
|
|
{ context: { statusCode: 401 } }
|
|
);
|
|
}
|
|
const handle = service.subscribe({
|
|
channel,
|
|
...(lastEventId !== undefined ? { lastEventId } : {}),
|
|
...(principalId !== undefined ? { principalId } : {}),
|
|
signal: c.req.raw.signal,
|
|
});
|
|
|
|
const encoder = new TextEncoder();
|
|
const instruments = getSseInstruments();
|
|
const started = performance.now();
|
|
let heartbeatHandle: unknown;
|
|
let controllerRef: ReadableStreamDefaultController<Uint8Array> | null =
|
|
null;
|
|
const backlog: Uint8Array[] = [];
|
|
|
|
const writeFrame = (frame: string): void => {
|
|
if (controllerRef === null) return;
|
|
const bytes = encoder.encode(frame);
|
|
backlog.push(bytes);
|
|
const evictedNewest = applyOverflowPolicy(
|
|
backlog,
|
|
options.queueHighWaterMark,
|
|
options.overflowPolicy
|
|
);
|
|
drainInto(controllerRef);
|
|
if (!evictedNewest) {
|
|
instruments.messageBytesTotal.add(bytes.byteLength);
|
|
}
|
|
};
|
|
|
|
// Drain queued bytes into the supplied controller while it
|
|
// signals capacity. Called from both writeFrame (which has
|
|
// already verified controllerRef is non-null) and the
|
|
// ReadableStream pull callback (which fires only while the
|
|
// controller is open).
|
|
const drainInto = (
|
|
ctrl: ReadableStreamDefaultController<Uint8Array>
|
|
): void => {
|
|
while (
|
|
backlog.length > 0 &&
|
|
(ctrl.desiredSize === null || ctrl.desiredSize > 0)
|
|
) {
|
|
const next = backlog.shift() as Uint8Array;
|
|
ctrl.enqueue(next);
|
|
}
|
|
};
|
|
|
|
const cleanup = async (): Promise<void> => {
|
|
controllerRef = null;
|
|
backlog.length = 0;
|
|
if (heartbeatHandle !== undefined) {
|
|
clearIntervalFn(heartbeatHandle);
|
|
heartbeatHandle = undefined;
|
|
}
|
|
await handle.close();
|
|
const duration = performance.now() - started;
|
|
instruments.connectionDurationMs.record(duration, { channel });
|
|
};
|
|
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start: (controller) => {
|
|
controllerRef = controller;
|
|
heartbeatHandle = setIntervalFn(() => {
|
|
writeFrame(formatHeartbeatFrame());
|
|
instruments.heartbeatsTotal.add(1);
|
|
}, options.heartbeatIntervalMs);
|
|
void (async () => {
|
|
try {
|
|
for await (const event of handle.events) {
|
|
writeFrame(formatSseFrame(event));
|
|
}
|
|
} finally {
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
await cleanup();
|
|
}
|
|
})();
|
|
},
|
|
pull: (controller) => {
|
|
drainInto(controller);
|
|
},
|
|
cancel: async () => {
|
|
await cleanup();
|
|
},
|
|
});
|
|
|
|
return c.body(body, 200, {
|
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
"X-Accel-Buffering": "no",
|
|
Connection: "keep-alive",
|
|
});
|
|
};
|
|
}
|