@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/worker.ts ADDED
@@ -0,0 +1,480 @@
1
+ import type { Removed, State } from "./digest.js";
2
+ import { createProfileOnlineSource, createProfileVisibilitySource } from "./fold.js";
3
+ import type { LifecycleEvent } from "./lifecycle.js";
4
+ import { createOnlineManager } from "./online.js";
5
+ import {
6
+ type PortRevalidateContext,
7
+ type TabToWorker,
8
+ type WorkerToTab,
9
+ isTabMessage,
10
+ } from "./port-protocol.js";
11
+ import {
12
+ type RevalidateContext,
13
+ type Stream,
14
+ type StreamOptions,
15
+ createStream,
16
+ streamInternals,
17
+ } from "./stream.js";
18
+ import { DEFAULT_TIMING, resolveTiming } from "./timing.js";
19
+ import { createVisibilityManager } from "./visibility.js";
20
+
21
+ /** The changed and removed sets of one host-side digest, fanned to the tabs with the run. */
22
+ export interface DigestVerdict {
23
+ readonly changed: readonly State[];
24
+ readonly removed: readonly Removed[];
25
+ }
26
+
27
+ /** The attached tabs a profile revalidation may be routed to. */
28
+ export interface TabSet {
29
+ /**
30
+ * Fans the run to every acknowledging tab; settles when each answered, expired or left.
31
+ * With `verdict`, each tab's context carries `changed` and `removed`, so one digest serves them all.
32
+ */
33
+ run(ctx: RevalidateContext, verdict?: DigestVerdict): Promise<void>;
34
+ size(): number;
35
+ }
36
+
37
+ export interface WorkerHostOptions extends Omit<
38
+ StreamOptions,
39
+ "visibility" | "online" | "onFrame" | "onLifecycle" | "revalidate"
40
+ > {
41
+ /** The application decides whether the body runs here or in the tabs it names. */
42
+ readonly revalidate: (ctx: RevalidateContext, tabs: TabSet) => Promise<void>;
43
+ readonly onLifecycle?: (ev: LifecycleEvent) => void;
44
+ /** Seed for the profile's network reading; defaults to the worker's navigator.onLine. */
45
+ readonly onlineSeed?: boolean;
46
+ readonly heartbeatMs?: number;
47
+ }
48
+
49
+ export interface WorkerHost {
50
+ /** Called from the SharedWorker's onconnect with each new port. */
51
+ attach(port: MessagePort): void;
52
+ stream(): Stream;
53
+ /** Stops the stream and closes every port. */
54
+ close(): void;
55
+ }
56
+
57
+ interface PortEntry {
58
+ readonly port: MessagePort;
59
+ tabId: string | null;
60
+ visible: boolean;
61
+ online: boolean;
62
+ lastAckAt: number;
63
+ unacked: number;
64
+ expired: boolean;
65
+ }
66
+
67
+ interface Quorum {
68
+ readonly pending: Set<PortEntry>;
69
+ readonly failures: string[];
70
+ readonly resolve: () => void;
71
+ readonly reject: (error: Error) => void;
72
+ }
73
+
74
+ const EXPIRY_BEATS = 3;
75
+
76
+ function statusOf(error: unknown): number | null {
77
+ if (typeof error !== "object" || error === null) {
78
+ return null;
79
+ }
80
+ const status = (error as { status?: unknown }).status;
81
+ return typeof status === "number" ? status : null;
82
+ }
83
+
84
+ function cloneSafe(event: LifecycleEvent): LifecycleEvent {
85
+ if (event.kind === "frame_rejected") {
86
+ return {
87
+ ...event,
88
+ error: event.error instanceof Error ? event.error.message : String(event.error),
89
+ };
90
+ }
91
+ return event;
92
+ }
93
+
94
+ function defaultOnline(): boolean {
95
+ const onLine = (globalThis as { readonly navigator?: { readonly onLine?: boolean } }).navigator
96
+ ?.onLine;
97
+ return onLine !== false;
98
+ }
99
+
100
+ /** Creates the profile's stream owner: one stream, one cursor, one version map, many tabs. */
101
+ export function createWorkerHost(opts: WorkerHostOptions): WorkerHost {
102
+ const cfg = resolveTiming(opts.timing);
103
+ const heartbeatMs = opts.heartbeatMs ?? DEFAULT_TIMING.heartbeatMs;
104
+ const headers: Record<string, string> = { ...opts.headers };
105
+ const visibility = createProfileVisibilitySource();
106
+ const online = createProfileOnlineSource(opts.onlineSeed ?? defaultOnline());
107
+ const entries = new Map<MessagePort, PortEntry>();
108
+ const byTab = new Map<string, PortEntry>();
109
+ const quorums = new Map<number, Quorum>();
110
+ let runSeq = 0;
111
+ let heartbeatSeq = 0;
112
+ let heartbeat: ReturnType<typeof setInterval> | null = null;
113
+ let tag: string | null = null;
114
+ let lastFrameFailed = -1;
115
+
116
+ const now = (): number => Date.now();
117
+
118
+ function active(): PortEntry[] {
119
+ const out: PortEntry[] = [];
120
+ for (const entry of entries.values()) {
121
+ if (entry.tabId !== null && !entry.expired) {
122
+ out.push(entry);
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function post(entry: PortEntry, message: WorkerToTab): void {
129
+ entry.port.postMessage(message);
130
+ }
131
+
132
+ function broadcast(message: WorkerToTab): void {
133
+ for (const entry of active()) {
134
+ post(entry, message);
135
+ }
136
+ }
137
+
138
+ /** Every record, the host's own included, reaches the application and every attached tab. */
139
+ function emit(event: LifecycleEvent): void {
140
+ opts.onLifecycle?.(event);
141
+ broadcast({ type: "lifecycle", event: cloneSafe(event) });
142
+ }
143
+
144
+ function portContext(ctx: RevalidateContext, verdict?: DigestVerdict): PortRevalidateContext {
145
+ const base = { cause: ctx.cause, epoch: ctx.epoch, generation: ctx.generation, full: ctx.full };
146
+ return verdict === undefined
147
+ ? base
148
+ : { ...base, changed: [...verdict.changed], removed: [...verdict.removed] };
149
+ }
150
+
151
+ const tabs: TabSet = {
152
+ run(ctx, verdict) {
153
+ const members = active();
154
+ if (members.length === 0) {
155
+ return Promise.resolve();
156
+ }
157
+ const runId = ++runSeq;
158
+ return new Promise<void>((resolve, reject) => {
159
+ const quorum: Quorum = { pending: new Set(members), failures: [], resolve, reject };
160
+ quorums.set(runId, quorum);
161
+ ctx.signal.addEventListener(
162
+ "abort",
163
+ () => {
164
+ if (quorums.delete(runId)) {
165
+ reject(new Error("revalidate run aborted"));
166
+ }
167
+ },
168
+ { once: true },
169
+ );
170
+ const message: WorkerToTab = {
171
+ type: "revalidate_run",
172
+ runId,
173
+ ctx: portContext(ctx, verdict),
174
+ };
175
+ for (const member of members) {
176
+ post(member, message);
177
+ }
178
+ });
179
+ },
180
+ size: () => active().length,
181
+ };
182
+
183
+ function settleQuorum(runId: number, quorum: Quorum): void {
184
+ if (quorum.pending.size > 0) {
185
+ return;
186
+ }
187
+ quorums.delete(runId);
188
+ const failure = quorum.failures[0];
189
+ if (failure === undefined) {
190
+ quorum.resolve();
191
+ } else {
192
+ quorum.reject(new Error(failure));
193
+ }
194
+ }
195
+
196
+ function leaveQuorums(entry: PortEntry): void {
197
+ for (const [runId, quorum] of quorums) {
198
+ if (quorum.pending.delete(entry)) {
199
+ settleQuorum(runId, quorum);
200
+ }
201
+ }
202
+ }
203
+
204
+ function authLost(route: string, status: number): void {
205
+ stream.stop();
206
+ emit({ kind: "auth_lost", route, status });
207
+ }
208
+
209
+ const stream = createStream({
210
+ ...opts,
211
+ headers,
212
+ visibility: createVisibilityManager(visibility),
213
+ online: createOnlineManager(online),
214
+ onFrame(frame) {
215
+ const message: WorkerToTab = { type: "frame", frame, generation: stream.state().generation };
216
+ for (const entry of active()) {
217
+ if (entry.unacked >= cfg.heldMaxFrames) {
218
+ expire(entry);
219
+ continue;
220
+ }
221
+ post(entry, message);
222
+ entry.unacked++;
223
+ }
224
+ },
225
+ onLifecycle(event) {
226
+ if (event.kind === "state") {
227
+ broadcast({ type: "state", state: stream.state() });
228
+ }
229
+ emit(event);
230
+ if (event.kind === "alive_ack" && event.status === 401) {
231
+ authLost("alive", 401);
232
+ }
233
+ },
234
+ async revalidate(ctx) {
235
+ try {
236
+ await opts.revalidate(ctx, tabs);
237
+ } catch (e: unknown) {
238
+ if (statusOf(e) === 401) {
239
+ authLost("digest", 401);
240
+ }
241
+ throw e;
242
+ }
243
+ },
244
+ });
245
+ const controls = streamInternals(stream);
246
+
247
+ function expire(entry: PortEntry): void {
248
+ if (entry.expired || entry.tabId === null) {
249
+ return;
250
+ }
251
+ entry.expired = true;
252
+ visibility.detach(entry.tabId);
253
+ online.detach(entry.tabId);
254
+ leaveQuorums(entry);
255
+ emit({ kind: "port_expired", sinceLastAckMs: now() - entry.lastAckAt });
256
+ }
257
+
258
+ function repair(entry: PortEntry): void {
259
+ if (entry.tabId === null) {
260
+ return;
261
+ }
262
+ entry.expired = false;
263
+ entry.unacked = 0;
264
+ visibility.attach(entry.tabId, entry.visible);
265
+ online.attach(entry.tabId, entry.online);
266
+ post(entry, { type: "stale" });
267
+ perPortFull(entry);
268
+ emit({ kind: "port_repaired" });
269
+ }
270
+
271
+ function perPortFull(entry: PortEntry): void {
272
+ post(entry, {
273
+ type: "revalidate_run",
274
+ runId: ++runSeq,
275
+ ctx: {
276
+ cause: "hello",
277
+ epoch: opts.versions.epoch(),
278
+ generation: stream.state().generation,
279
+ full: true,
280
+ },
281
+ });
282
+ }
283
+
284
+ // A replaced member keeps its fold entry: the new port takes it over without a hidden flip.
285
+ function remove(entry: PortEntry, cause: string, replaced = false): void {
286
+ entries.delete(entry.port);
287
+ if (entry.tabId !== null) {
288
+ if (byTab.get(entry.tabId) === entry) {
289
+ byTab.delete(entry.tabId);
290
+ }
291
+ if (!replaced) {
292
+ visibility.detach(entry.tabId);
293
+ online.detach(entry.tabId);
294
+ }
295
+ leaveQuorums(entry);
296
+ }
297
+ entry.port.onmessage = null;
298
+ entry.port.close();
299
+ emit({ kind: "tab_detached", cause });
300
+ if (entries.size === 0) {
301
+ stopHeartbeat();
302
+ }
303
+ }
304
+
305
+ // The empty tag means "none", so it never displaces one a tab already presented.
306
+ function adoptTag(next: string): void {
307
+ if (next === "" || next === tag) {
308
+ return;
309
+ }
310
+ tag = next;
311
+ headers["SSE-Client"] = next;
312
+ stream.reconnect();
313
+ }
314
+
315
+ function onAttach(entry: PortEntry, message: Extract<TabToWorker, { type: "attach" }>): void {
316
+ const previous = byTab.get(message.tabId);
317
+ const replaced = previous !== undefined && previous !== entry;
318
+ if (replaced) {
319
+ remove(previous, "replaced", true);
320
+ }
321
+ adoptTag(message.tag);
322
+ entry.tabId = message.tabId;
323
+ entry.visible = message.visible;
324
+ entry.online = message.online;
325
+ entry.lastAckAt = now();
326
+ entry.unacked = 0;
327
+ entry.expired = false;
328
+ byTab.set(message.tabId, entry);
329
+ const stopped = stream.state().kind === "stopped";
330
+ if (stopped && message.hadWorker) {
331
+ controls.queueFull();
332
+ }
333
+ visibility.attach(message.tabId, message.visible);
334
+ online.attach(message.tabId, message.online);
335
+ if (stopped) {
336
+ stream.start();
337
+ } else if (message.hadWorker) {
338
+ perPortFull(entry);
339
+ }
340
+ const state = stream.state();
341
+ post(entry, { type: "state", state });
342
+ emit({ kind: "tab_attached", replaced, state: state.kind });
343
+ }
344
+
345
+ function onMessage(entry: PortEntry, message: TabToWorker): void {
346
+ if (message.type === "attach") {
347
+ onAttach(entry, message);
348
+ return;
349
+ }
350
+ if (message.type === "ping") {
351
+ post(entry, { type: "pong", seq: message.seq });
352
+ return;
353
+ }
354
+ if (entry.tabId === null) {
355
+ return;
356
+ }
357
+ switch (message.type) {
358
+ case "visibility":
359
+ if (message.ev === "pagehide") {
360
+ remove(entry, "pagehide");
361
+ return;
362
+ }
363
+ if (message.ev === "visible") {
364
+ entry.visible = true;
365
+ } else if (message.ev === "hidden") {
366
+ entry.visible = false;
367
+ }
368
+ if (!entry.expired) {
369
+ visibility.report(entry.tabId, message.ev);
370
+ }
371
+ return;
372
+ case "network":
373
+ entry.online = message.online;
374
+ if (!entry.expired) {
375
+ online.report(entry.tabId, message.online);
376
+ }
377
+ return;
378
+ case "detach":
379
+ remove(entry, message.cause);
380
+ if (message.cause === "logout") {
381
+ stream.stop();
382
+ }
383
+ return;
384
+ case "heartbeat_ack":
385
+ entry.lastAckAt = now();
386
+ entry.unacked = 0;
387
+ if (entry.expired) {
388
+ repair(entry);
389
+ }
390
+ return;
391
+ case "observed":
392
+ opts.versions.observe(message.subject, message.version, message.epoch);
393
+ return;
394
+ case "reconnect":
395
+ if (message.resetCursor) {
396
+ stream.resetCursor();
397
+ }
398
+ stream.reconnect();
399
+ return;
400
+ case "set_tag":
401
+ adoptTag(message.tag);
402
+ return;
403
+ case "revalidate_done":
404
+ case "revalidate_failed": {
405
+ const quorum = quorums.get(message.runId);
406
+ if (!quorum?.pending.delete(entry)) {
407
+ return;
408
+ }
409
+ if (message.type === "revalidate_failed") {
410
+ quorum.failures.push(message.cause);
411
+ }
412
+ settleQuorum(message.runId, quorum);
413
+ return;
414
+ }
415
+ case "frame_failed": {
416
+ const generation = stream.state().generation;
417
+ if (message.generation !== generation || lastFrameFailed === generation) {
418
+ return;
419
+ }
420
+ lastFrameFailed = generation;
421
+ emit({ kind: "frame_rejected", type: "tab", error: message.cursor });
422
+ controls.revalidate("hello", false);
423
+ return;
424
+ }
425
+ }
426
+ }
427
+
428
+ function tick(): void {
429
+ const seq = ++heartbeatSeq;
430
+ const threshold = EXPIRY_BEATS * heartbeatMs;
431
+ for (const entry of [...entries.values()]) {
432
+ post(entry, { type: "heartbeat", seq });
433
+ if (entry.tabId !== null && !entry.expired && now() - entry.lastAckAt > threshold) {
434
+ expire(entry);
435
+ }
436
+ }
437
+ }
438
+
439
+ function startHeartbeat(): void {
440
+ heartbeat ??= setInterval(tick, heartbeatMs);
441
+ }
442
+
443
+ function stopHeartbeat(): void {
444
+ if (heartbeat !== null) {
445
+ clearInterval(heartbeat);
446
+ heartbeat = null;
447
+ }
448
+ }
449
+
450
+ return {
451
+ attach(port) {
452
+ const entry: PortEntry = {
453
+ port,
454
+ tabId: null,
455
+ visible: false,
456
+ online: true,
457
+ lastAckAt: now(),
458
+ unacked: 0,
459
+ expired: false,
460
+ };
461
+ entries.set(port, entry);
462
+ port.onmessage = (event: MessageEvent) => {
463
+ const data: unknown = event.data;
464
+ if (entries.get(port) === entry && isTabMessage(data)) {
465
+ onMessage(entry, data);
466
+ }
467
+ };
468
+ port.start();
469
+ startHeartbeat();
470
+ },
471
+ stream: () => stream,
472
+ close() {
473
+ stopHeartbeat();
474
+ for (const entry of [...entries.values()]) {
475
+ remove(entry, "closed");
476
+ }
477
+ stream.stop();
478
+ },
479
+ };
480
+ }