@cplieger/sse 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tab.ts ADDED
@@ -0,0 +1,458 @@
1
+ import type { LifecycleEvent } from "./lifecycle.js";
2
+ import { type OnlineManager, createOnlineManager } from "./online.js";
3
+ import {
4
+ type PortRevalidateContext,
5
+ type TabToWorker,
6
+ type WorkerToTab,
7
+ isWorkerMessage,
8
+ } from "./port-protocol.js";
9
+ import type { ClientState } from "./reducer.js";
10
+ import type { Frame, Stream } from "./stream.js";
11
+ import { DEFAULT_TIMING } from "./timing.js";
12
+ import type { Subject, VersionMap } from "./versions.js";
13
+ import {
14
+ type VisibilityInput,
15
+ type VisibilityManager,
16
+ adaptVisibility,
17
+ createVisibilityManager,
18
+ } from "./visibility.js";
19
+
20
+ /** What a host-routed run hands this tab: the port context plus the tab's own signal. */
21
+ export type TabRevalidateContext = PortRevalidateContext & { readonly signal: AbortSignal };
22
+
23
+ /** What `spawn()` returns: a SharedWorker, or anything with its port and error event. */
24
+ export interface SharedWorkerLike {
25
+ readonly port: MessagePort;
26
+ addEventListener(type: "error", listener: () => void): void;
27
+ }
28
+
29
+ /** What `fallback()` returns: the per-tab stream and the two records the tab writes into it. */
30
+ export interface TabFallback {
31
+ readonly stream: Stream;
32
+ /** The map the stream was built on; `observe()` records into it. */
33
+ readonly versions: VersionMap;
34
+ /** The record the stream reads at every request; the tab writes `SSE-Client` into it. */
35
+ readonly headers: Record<string, string>;
36
+ }
37
+
38
+ export interface AttachOptions {
39
+ /** Constructs the worker with the fixed URL, name and options; called on every spawn. */
40
+ readonly spawn: () => SharedWorkerLike;
41
+ /** Constructs the per-tab stream when the ladder gives up on the worker. */
42
+ readonly fallback: () => TabFallback;
43
+ readonly onFrame: (frame: Frame) => void;
44
+ readonly onLifecycle?: (ev: LifecycleEvent) => void;
45
+ /** The body, when the host routes a run to this tab. */
46
+ readonly revalidate?: (ctx: TabRevalidateContext) => Promise<void>;
47
+ readonly visibility?: VisibilityManager;
48
+ readonly online?: OnlineManager;
49
+ readonly heartbeatMs?: number;
50
+ /** The SSE-Client value this tab computed for the profile. */
51
+ readonly tag?: string;
52
+ /** Overrides the `typeof SharedWorker` presence test. */
53
+ readonly supported?: boolean;
54
+ /** Bound on a run routed to this tab; defaults to revalidateTimeoutMs. */
55
+ readonly revalidateTimeoutMs?: number;
56
+ }
57
+
58
+ export interface TabAttachment {
59
+ /** Leaves the host; `logout` also ends the profile's stream, or this tab's fallback stream. */
60
+ detach(cause?: "unload" | "logout"): void;
61
+ /** The host's last state broadcast, or the fallback stream's own state. */
62
+ state(): ClientState | null;
63
+ mode(): "worker" | "fallback";
64
+ /** Reports a stamp this tab applied, into the host's version map or the fallback's. */
65
+ observe(subject: Subject, version: string, epoch: string): void;
66
+ /** Reconnects the stream this tab is attached to; `resetCursor` drops the cursor first. */
67
+ reconnect(opts?: { readonly resetCursor?: boolean }): void;
68
+ /** Presents `tag` as SSE-Client from the next connect, reconnecting once; "" is ignored. */
69
+ setTag(tag: string): void;
70
+ }
71
+
72
+ const WINDOW_BEATS = 3;
73
+
74
+ interface Link {
75
+ readonly worker: SharedWorkerLike;
76
+ readonly port: MessagePort;
77
+ }
78
+
79
+ function randomId(): string {
80
+ return crypto.randomUUID();
81
+ }
82
+
83
+ function hasSharedWorker(): boolean {
84
+ return typeof (globalThis as { SharedWorker?: unknown }).SharedWorker !== "undefined";
85
+ }
86
+
87
+ /** Attaches this tab to the profile's worker host, or runs the per-tab stream when it cannot. */
88
+ export function attachToWorker(opts: AttachOptions): TabAttachment {
89
+ const heartbeatMs = opts.heartbeatMs ?? DEFAULT_TIMING.heartbeatMs;
90
+ const windowMs = WINDOW_BEATS * heartbeatMs;
91
+ const runTimeoutMs = opts.revalidateTimeoutMs ?? DEFAULT_TIMING.revalidateTimeoutMs;
92
+ const visibility = opts.visibility ?? createVisibilityManager();
93
+ const online = opts.online ?? createOnlineManager();
94
+ const tabId = randomId();
95
+ let tag = opts.tag ?? "";
96
+
97
+ let mode: "worker" | "fallback" = "worker";
98
+ let link: Link | null = null;
99
+ let fallback: TabFallback | null = null;
100
+ let lastState: ClientState | null = null;
101
+ let watchdog: ReturnType<typeof setTimeout> | null = null;
102
+ let lastPortMessageAt = Date.now();
103
+ let heartbeatSeen = false;
104
+ let silentSpawns = 0;
105
+ let pingSeq = 0;
106
+ let probe: { readonly link: Link; readonly timer: ReturnType<typeof setTimeout> } | null = null;
107
+ let triedThisVisible = false;
108
+ let detached = false;
109
+
110
+ const now = (): number => Date.now();
111
+
112
+ function emit(event: LifecycleEvent): void {
113
+ opts.onLifecycle?.(event);
114
+ }
115
+
116
+ function post(message: TabToWorker): void {
117
+ link?.port.postMessage(message);
118
+ }
119
+
120
+ function disarmWatchdog(): void {
121
+ if (watchdog !== null) {
122
+ clearTimeout(watchdog);
123
+ watchdog = null;
124
+ }
125
+ }
126
+
127
+ function armWatchdog(): void {
128
+ disarmWatchdog();
129
+ watchdog = setTimeout(onWatchdog, windowMs);
130
+ }
131
+
132
+ function abandon(): void {
133
+ disarmWatchdog();
134
+ if (link === null) {
135
+ return;
136
+ }
137
+ link.port.onmessage = null;
138
+ link.port.close();
139
+ link = null;
140
+ }
141
+
142
+ function attachMessage(hadWorker: boolean): TabToWorker {
143
+ return {
144
+ type: "attach",
145
+ tabId,
146
+ visible: visibility.isVisible(),
147
+ online: online.isOnline(),
148
+ hadWorker,
149
+ tag,
150
+ };
151
+ }
152
+
153
+ function spawnLink(): Link | null {
154
+ let worker: SharedWorkerLike;
155
+ try {
156
+ worker = opts.spawn();
157
+ } catch {
158
+ return null;
159
+ }
160
+ return { worker, port: worker.port };
161
+ }
162
+
163
+ function spawnAndAttach(hadWorker: boolean): void {
164
+ const next = spawnLink();
165
+ if (next === null) {
166
+ fallbackNow("error");
167
+ return;
168
+ }
169
+ link = next;
170
+ heartbeatSeen = false;
171
+ next.worker.addEventListener("error", () => {
172
+ if (link === next) {
173
+ fallbackNow("error");
174
+ }
175
+ });
176
+ next.port.onmessage = (event: MessageEvent) => {
177
+ if (link === next) {
178
+ onMessage(event.data);
179
+ }
180
+ };
181
+ next.port.start();
182
+ post(attachMessage(hadWorker));
183
+ emit({ kind: "worker_spawned" });
184
+ lastPortMessageAt = now();
185
+ if (visibility.isVisible()) {
186
+ armWatchdog();
187
+ }
188
+ }
189
+
190
+ function runRevalidate(port: MessagePort, runId: number, ctx: PortRevalidateContext): void {
191
+ const reply = (message: TabToWorker): void => {
192
+ if (link?.port === port) {
193
+ port.postMessage(message);
194
+ }
195
+ };
196
+ if (opts.revalidate === undefined) {
197
+ reply({ type: "revalidate_done", runId });
198
+ return;
199
+ }
200
+ const controller = new AbortController();
201
+ const timer = setTimeout(() => {
202
+ controller.abort(new DOMException("revalidate timed out", "TimeoutError"));
203
+ }, runTimeoutMs);
204
+ let promise: Promise<void>;
205
+ try {
206
+ promise = Promise.resolve(opts.revalidate({ ...ctx, signal: controller.signal }));
207
+ } catch (e: unknown) {
208
+ promise = Promise.reject(e instanceof Error ? e : new Error(String(e)));
209
+ }
210
+ promise
211
+ .then(
212
+ () => {
213
+ reply({ type: "revalidate_done", runId });
214
+ },
215
+ (e: unknown) => {
216
+ reply({
217
+ type: "revalidate_failed",
218
+ runId,
219
+ cause: e instanceof Error ? e.message : String(e),
220
+ });
221
+ },
222
+ )
223
+ .finally(() => {
224
+ clearTimeout(timer);
225
+ });
226
+ }
227
+
228
+ function onMessage(data: unknown): void {
229
+ if (!isWorkerMessage(data) || link === null) {
230
+ return;
231
+ }
232
+ lastPortMessageAt = now();
233
+ if (visibility.isVisible()) {
234
+ armWatchdog();
235
+ }
236
+ const message: WorkerToTab = data;
237
+ switch (message.type) {
238
+ case "heartbeat":
239
+ heartbeatSeen = true;
240
+ silentSpawns = 0;
241
+ post({ type: "heartbeat_ack", seq: message.seq });
242
+ return;
243
+ case "pong":
244
+ case "stale":
245
+ return;
246
+ case "frame":
247
+ try {
248
+ opts.onFrame(message.frame);
249
+ } catch {
250
+ post({ type: "frame_failed", generation: message.generation, cursor: message.frame.id });
251
+ }
252
+ return;
253
+ case "lifecycle":
254
+ emit(message.event);
255
+ return;
256
+ case "revalidate_run":
257
+ runRevalidate(link.port, message.runId, message.ctx);
258
+ return;
259
+ case "state":
260
+ lastState = message.state;
261
+ return;
262
+ }
263
+ }
264
+
265
+ function onWatchdog(): void {
266
+ watchdog = null;
267
+ if (link === null || !visibility.isVisible()) {
268
+ return;
269
+ }
270
+ emit({ kind: "worker_dead", sinceLastPortMessageMs: now() - lastPortMessageAt });
271
+ silentSpawns = heartbeatSeen ? 0 : silentSpawns + 1;
272
+ if (silentSpawns >= 2) {
273
+ fallbackNow("silent");
274
+ return;
275
+ }
276
+ post({ type: "detach", cause: "respawn" });
277
+ abandon();
278
+ spawnAndAttach(true);
279
+ }
280
+
281
+ function startFallback(): void {
282
+ fallback = opts.fallback();
283
+ if (tag !== "") {
284
+ fallback.headers["SSE-Client"] = tag;
285
+ }
286
+ fallback.stream.start();
287
+ }
288
+
289
+ function fallbackNow(cause: "silent" | "error"): void {
290
+ post({ type: "detach", cause: "fallback" });
291
+ abandon();
292
+ mode = "fallback";
293
+ triedThisVisible = true;
294
+ startFallback();
295
+ emit({ kind: "worker_unavailable", cause });
296
+ }
297
+
298
+ function closeProbe(): void {
299
+ if (probe === null) {
300
+ return;
301
+ }
302
+ clearTimeout(probe.timer);
303
+ probe.link.port.onmessage = null;
304
+ probe.link.port.close();
305
+ probe = null;
306
+ }
307
+
308
+ function tryRecover(): void {
309
+ const next = spawnLink();
310
+ if (next === null) {
311
+ return;
312
+ }
313
+ const current: { readonly link: Link; readonly timer: ReturnType<typeof setTimeout> } = {
314
+ link: next,
315
+ timer: setTimeout(closeProbe, windowMs),
316
+ };
317
+ probe = current;
318
+ next.worker.addEventListener("error", () => {
319
+ if (probe === current) {
320
+ closeProbe();
321
+ }
322
+ });
323
+ next.port.onmessage = (event: MessageEvent) => {
324
+ const data: unknown = event.data;
325
+ if (probe !== current || !isWorkerMessage(data) || data.type !== "heartbeat") {
326
+ return;
327
+ }
328
+ clearTimeout(current.timer);
329
+ probe = null;
330
+ fallback?.stream.stop();
331
+ fallback = null;
332
+ mode = "worker";
333
+ link = next;
334
+ heartbeatSeen = true;
335
+ silentSpawns = 0;
336
+ next.port.onmessage = (inner: MessageEvent) => {
337
+ if (link === next) {
338
+ onMessage(inner.data);
339
+ }
340
+ };
341
+ post({ type: "heartbeat_ack", seq: data.seq });
342
+ post(attachMessage(true));
343
+ lastPortMessageAt = now();
344
+ if (visibility.isVisible()) {
345
+ armWatchdog();
346
+ }
347
+ emit({ kind: "worker_recovered" });
348
+ };
349
+ next.port.start();
350
+ }
351
+
352
+ function onVisibility(input: VisibilityInput): void {
353
+ if (mode === "fallback") {
354
+ if (input === "visible" && !triedThisVisible) {
355
+ triedThisVisible = true;
356
+ tryRecover();
357
+ } else if (input === "hidden" || input === "pagehide") {
358
+ triedThisVisible = false;
359
+ closeProbe();
360
+ }
361
+ return;
362
+ }
363
+ switch (input) {
364
+ case "visible":
365
+ post({ type: "visibility", ev: "visible" });
366
+ post({ type: "ping", seq: ++pingSeq });
367
+ if (link !== null) {
368
+ lastPortMessageAt = now();
369
+ armWatchdog();
370
+ }
371
+ return;
372
+ case "hidden":
373
+ post({ type: "visibility", ev: "hidden" });
374
+ disarmWatchdog();
375
+ return;
376
+ case "pagehide":
377
+ post({ type: "visibility", ev: "pagehide" });
378
+ abandon();
379
+ return;
380
+ case "pageshow":
381
+ if (link === null) {
382
+ spawnAndAttach(true);
383
+ } else {
384
+ post({ type: "visibility", ev: "pageshow" });
385
+ }
386
+ return;
387
+ }
388
+ }
389
+
390
+ const unsubscribes = [
391
+ adaptVisibility(
392
+ visibility,
393
+ { visible: () => visibility.isVisible(), listen: () => () => undefined },
394
+ onVisibility,
395
+ ),
396
+ online.subscribe((value) => {
397
+ post({ type: "network", online: value });
398
+ }),
399
+ ];
400
+
401
+ if (opts.supported ?? hasSharedWorker()) {
402
+ spawnAndAttach(false);
403
+ } else {
404
+ mode = "fallback";
405
+ startFallback();
406
+ }
407
+
408
+ return {
409
+ detach(cause = "unload") {
410
+ if (detached) {
411
+ return;
412
+ }
413
+ detached = true;
414
+ post({ type: "detach", cause });
415
+ abandon();
416
+ closeProbe();
417
+ for (const unsubscribe of unsubscribes) {
418
+ unsubscribe();
419
+ }
420
+ fallback?.stream.stop();
421
+ },
422
+ state: () => (mode === "fallback" ? (fallback?.stream.state() ?? null) : lastState),
423
+ mode: () => mode,
424
+ observe(subject, version, epoch) {
425
+ if (mode === "fallback") {
426
+ fallback?.versions.observe(subject, version, epoch);
427
+ return;
428
+ }
429
+ post({ type: "observed", subject, version, epoch });
430
+ },
431
+ reconnect(reconnectOpts) {
432
+ const resetCursor = reconnectOpts?.resetCursor ?? false;
433
+ if (mode === "fallback") {
434
+ if (resetCursor) {
435
+ fallback?.stream.resetCursor();
436
+ }
437
+ fallback?.stream.reconnect();
438
+ return;
439
+ }
440
+ post({ type: "reconnect", resetCursor });
441
+ },
442
+ setTag(next) {
443
+ if (next === "") {
444
+ return;
445
+ }
446
+ const changed = next !== tag;
447
+ tag = next;
448
+ if (mode === "fallback") {
449
+ if (changed && fallback !== null) {
450
+ fallback.headers["SSE-Client"] = next;
451
+ fallback.stream.reconnect();
452
+ }
453
+ return;
454
+ }
455
+ post({ type: "set_tag", tag: next });
456
+ },
457
+ };
458
+ }
package/src/timing.ts ADDED
@@ -0,0 +1,98 @@
1
+ /** Wire revision this client speaks; a hello outside [minWire, maxWire] is refused. */
2
+ export const WIRE = 1;
3
+
4
+ /** Largest encoded frame either side accepts, terminating blank line included. */
5
+ export const MAX_FRAME_BYTES = 1_048_576;
6
+
7
+ /** Largest offset a cursor may carry: 2^53 - 1, the JavaScript exact-integer bound. */
8
+ export const MAX_OFFSET = 9_007_199_254_740_991n;
9
+
10
+ /** MAX_OFFSET as the decimal string it takes on the wire. */
11
+ export const MAX_OFFSET_STRING = "9007199254740991";
12
+
13
+ /** Server keepalive interval the hello reports when the hub runs at its default. */
14
+ export const DEFAULT_KEEPALIVE_MS = 15_000;
15
+
16
+ /** The `retry:` value the server writes when it runs at its default. */
17
+ export const DEFAULT_RETRY_MS = 1_500;
18
+
19
+ /** Event name of the server keepalive frame when the hub runs at its default. */
20
+ export const KEEPALIVE_EVENT = "sse:keepalive";
21
+
22
+ /** Subjects one digest request may carry when the server runs at its default. */
23
+ export const DIGEST_MAX_SUBJECTS = 256;
24
+
25
+ /** Client-side timing knobs. Every field has a default in DEFAULT_TIMING. */
26
+ export interface TimingConfig {
27
+ /** Deadline from fetch start to response headers. */
28
+ readonly connectTimeoutMs: number;
29
+ /** Deadline from response headers to a complete, valid hello. */
30
+ readonly helloTimeoutMs: number;
31
+ /** Bound on one revalidate run; the run's signal aborts at it. */
32
+ readonly revalidateTimeoutMs: number;
33
+ /** Deadline on one digest POST. */
34
+ readonly digestTimeoutMs: number;
35
+ /** Frames a hold may queue before the connection ends with hold_overflow. */
36
+ readonly heldMaxFrames: number;
37
+ /** Encoded bytes a hold may queue before the connection ends with hold_overflow. */
38
+ readonly heldMaxBytes: number;
39
+ /** Full-jitter backoff base. */
40
+ readonly baseMs: number;
41
+ /** Full-jitter backoff cap. */
42
+ readonly capMs: number;
43
+ /** Connected time after which the backoff attempt counter resets. */
44
+ readonly stableMs: number;
45
+ /** Hidden time after which the stream is closed deliberately. */
46
+ readonly hiddenCloseAfterMs: number;
47
+ /** Minimum spacing between two wake-driven revalidations. */
48
+ readonly wakeThrottleMs: number;
49
+ /** Worker-host port heartbeat interval. */
50
+ readonly heartbeatMs: number;
51
+ /** Keepalives received per alive acknowledgement POST. */
52
+ readonly aliveEveryBeats: number;
53
+ /** Missed keepalives before the silence watchdog fires. */
54
+ readonly watchdogBeats: number;
55
+ /** Lower bound on the watchdog interval whatever the keepalive is. */
56
+ readonly watchdogFloorMs: number;
57
+ /** Parser buffer cap; must be at least MAX_FRAME_BYTES. */
58
+ readonly maxBufferBytes: number;
59
+ }
60
+
61
+ export const DEFAULT_TIMING: TimingConfig = {
62
+ connectTimeoutMs: 15_000,
63
+ helloTimeoutMs: 10_000,
64
+ revalidateTimeoutMs: 30_000,
65
+ digestTimeoutMs: 10_000,
66
+ heldMaxFrames: 2_000,
67
+ heldMaxBytes: 64 * MAX_FRAME_BYTES,
68
+ baseMs: 500,
69
+ capMs: 30_000,
70
+ stableMs: 30_000,
71
+ hiddenCloseAfterMs: 60_000,
72
+ wakeThrottleMs: 1_000,
73
+ heartbeatMs: 5_000,
74
+ aliveEveryBeats: 1,
75
+ watchdogBeats: 3,
76
+ watchdogFloorMs: 15_000,
77
+ maxBufferBytes: MAX_FRAME_BYTES,
78
+ };
79
+
80
+ // Silence watchdog derivation follows Yaffle/EventSource: max(3 beats, 15 s), 45 s at the default keepalive.
81
+ export function watchdogMs(keepaliveMs: number, cfg: TimingConfig = DEFAULT_TIMING): number {
82
+ return Math.max(cfg.watchdogBeats * keepaliveMs, cfg.watchdogFloorMs);
83
+ }
84
+
85
+ /**
86
+ * Fills a partial timing with the defaults. Throws RangeError when maxBufferBytes is
87
+ * below MAX_FRAME_BYTES: the two caps are one wire rule, and a lower client cap would
88
+ * make a legal ring entry abort every reconnect.
89
+ */
90
+ export function resolveTiming(partial: Partial<TimingConfig> = {}): TimingConfig {
91
+ const cfg: TimingConfig = { ...DEFAULT_TIMING, ...partial };
92
+ if (cfg.maxBufferBytes < MAX_FRAME_BYTES) {
93
+ throw new RangeError(
94
+ `maxBufferBytes ${String(cfg.maxBufferBytes)} is below MAX_FRAME_BYTES ${String(MAX_FRAME_BYTES)}`,
95
+ );
96
+ }
97
+ return cfg;
98
+ }