Files
2026-07-13 13:32:57 +08:00

240 lines
8.4 KiB
TypeScript

import { type LoaderFunctionArgs } from "@remix-run/node";
import { type Params } from "@remix-run/router";
import { eventStream } from "remix-utils/sse/server";
import { setInterval } from "timers/promises";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
export type SendFunction = Parameters<Parameters<typeof eventStream>[1]>[0];
type HandlerParams = {
send: SendFunction;
};
type SSEHandlers = {
/** Return false to stop */
beforeStream?: () => Promise<boolean | void> | boolean | void;
/** Return false to stop */
initStream?: (params: HandlerParams) => Promise<boolean | void> | boolean | void;
/** Return false to stop */
iterator?: (params: HandlerParams & { date: Date }) => Promise<boolean | void> | boolean | void;
cleanup?: (params: HandlerParams) => void;
};
type SSEContext = {
id: string;
request: Request;
params: Params<string>;
controller: AbortController;
debug: (message: string) => void;
};
type SSEOptions = {
timeout: number;
interval?: number;
debug?: boolean;
handler: (context: SSEContext) => Promise<SSEHandlers>;
};
// This is used to track the open connections, for debugging
const connections: Set<string> = new Set();
// Stackless sentinel reasons passed to AbortController#abort. Calling .abort()
// with no argument produces a DOMException that captures a ~500-byte stack
// trace; a string reason is stored verbatim with no stack. The choice of
// reason type does not cause the retention we saw in prod (that was the
// AbortSignal.any composite — see comment near the timeoutTimer below for the
// Node issue refs), but naming the sentinels keeps call sites readable and
// lets future signal.reason consumers branch on the cause.
export const ABORT_REASON_REQUEST = "request_aborted";
export const ABORT_REASON_TIMEOUT = "timeout";
export const ABORT_REASON_SEND_ERROR = "send_error";
export const ABORT_REASON_INIT_STOP = "init_requested_stop";
export const ABORT_REASON_ITERATOR_STOP = "iterator_requested_stop";
export const ABORT_REASON_ITERATOR_ERROR = "iterator_error";
export function createSSELoader(options: SSEOptions) {
const { timeout, interval = 500, debug = false, handler } = options;
return async function loader({ request, params }: LoaderFunctionArgs) {
const id = request.headers.get("x-request-id") || Math.random().toString(36).slice(2, 8);
const internalController = new AbortController();
const log = (message: string) => {
if (debug)
console.log(
`SSE: [${request.url} ${id}] ${message} (${connections.size} open connections)`
);
};
const createSafeSend = (originalSend: SendFunction): SendFunction => {
return (event) => {
try {
if (!internalController.signal.aborted) {
originalSend(event);
}
} catch (error) {
if (error instanceof Error) {
if (error.message?.includes("Controller is already closed")) {
return;
}
log(`Error sending event: ${error.message}`);
}
// Abort before rethrowing so timer + request-abort listener are cleaned
// up immediately. Otherwise a send-failure in initStream leaves them
// alive until `timeout` fires.
if (!internalController.signal.aborted) {
internalController.abort(ABORT_REASON_SEND_ERROR);
}
throw error;
}
};
};
const context: SSEContext = {
id,
request,
params,
controller: internalController,
debug: log,
};
const handlers = await handler(context).catch((error) => {
if (error instanceof Response) {
throw error;
}
throw new Response("Internal Server Error", { status: 500 });
});
const requestAbortSignal = getRequestAbortSignal();
log("Start");
// Single-signal abort chain: everything rolls up into internalController.
// Timeout is a plain setTimeout cleared on abort rather than an
// AbortSignal.timeout() combined via AbortSignal.any() — AbortSignal.any
// keeps its source signals in an internal Set<WeakRef> managed by a
// FinalizationRegistry, and under sustained request traffic those entries
// accumulate faster than they get cleaned up, pinning every source signal
// (and its listeners, and anything those listeners close over) until the
// parent signal is GC'd or aborts. Reproduced locally in isolation; shape
// matches the ChainSafe Lodestar production case described in
// nodejs/node#54614. See also nodejs/node#55351 (mechanism confirmed by
// @jasnell, narrow fix in 22.12.0 via #55354) and nodejs/node#57584
// (circular-dep variant, still open).
const timeoutTimer = setTimeout(() => {
if (!internalController.signal.aborted) internalController.abort(ABORT_REASON_TIMEOUT);
}, timeout);
const onRequestAbort = () => {
log("request signal aborted");
if (!internalController.signal.aborted) internalController.abort(ABORT_REASON_REQUEST);
};
internalController.signal.addEventListener(
"abort",
() => {
clearTimeout(timeoutTimer);
requestAbortSignal.removeEventListener("abort", onRequestAbort);
},
{ once: true }
);
// The request could have been aborted during `await handler(context)` above.
// AbortSignal listeners added after the signal is already aborted never fire,
// so invoke cleanup synchronously in that case instead of waiting for `timeout`.
if (requestAbortSignal.aborted) {
onRequestAbort();
} else {
requestAbortSignal.addEventListener("abort", onRequestAbort, { once: true });
}
if (handlers.beforeStream) {
const shouldContinue = await handlers.beforeStream();
if (shouldContinue === false) {
log("beforeStream returned false, so we'll exit before creating the stream");
internalController.abort(ABORT_REASON_INIT_STOP);
return;
}
}
return eventStream(internalController.signal, function setup(send) {
connections.add(id);
const safeSend = createSafeSend(send);
async function run() {
try {
log("Initializing");
if (handlers.initStream) {
const shouldContinue = await handlers.initStream({ send: safeSend });
if (shouldContinue === false) {
log("initStream returned false, so we'll stop the stream");
internalController.abort(ABORT_REASON_INIT_STOP);
return;
}
}
log("Starting interval");
for await (const _ of setInterval(interval, null, {
signal: internalController.signal,
})) {
log("PING");
const date = new Date();
if (handlers.iterator) {
try {
const shouldContinue = await handlers.iterator({ date, send: safeSend });
if (shouldContinue === false) {
log("iterator return false, so we'll stop the stream");
internalController.abort(ABORT_REASON_ITERATOR_STOP);
break;
}
} catch (error) {
log("iterator threw an error, aborting stream");
// Immediately abort to trigger cleanup
if (error instanceof Error && error.name !== "AbortError") {
log(`iterator error: ${error.message}`);
}
internalController.abort(ABORT_REASON_ITERATOR_ERROR);
// No need to re-throw as we're handling it by aborting
return; // Exit the run function immediately
}
}
}
log("iterator finished all iterations");
} catch (error) {
if (error instanceof Error) {
if (error.name !== "AbortError") {
console.error(error);
}
}
} finally {
log("iterator finished");
}
}
run();
return () => {
connections.delete(id);
log("Cleanup called");
if (handlers.cleanup) {
try {
handlers.cleanup({ send: safeSend });
} catch (error) {
log(
`Error in cleanup handler: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
console.error("SSE Cleanup Error:", error);
}
}
};
});
};
}