-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathsse.ts
More file actions
239 lines (209 loc) · 8.36 KB
/
sse.ts
File metadata and controls
239 lines (209 loc) · 8.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
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);
}
}
};
});
};
}