@cplieger/sse 1.3.0-dev.4 → 1.5.0-dev.4
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/NOTICE +2 -2
- package/README.md +4 -2
- package/jsr.json +1 -1
- package/package.json +15 -19
- package/src/lifecycle.ts +1 -1
- package/src/port-protocol.ts +7 -1
- package/src/stream.ts +31 -1
- package/src/tab.ts +3 -1
- package/src/timing.ts +1 -1
- package/src/worker.ts +15 -32
package/NOTICE
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
|
|
1
|
+
go
|
|
2
2
|
Copyright 2026 cplieger
|
|
3
|
-
https://github.com/cplieger/
|
|
3
|
+
https://github.com/cplieger/go
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> A Server-Sent-Events client that resumes exactly where it stopped, notices a dead stream, and reconciles after a sleep.
|
|
4
4
|
|
|
5
|
-
The TypeScript half of [cplieger/sse](https://github.com/cplieger/sse), speaking the wire the Go `sse` package serves: every frame carries an `epoch:offset` cursor, every connection opens with a hello that says whether that cursor was honoured, and the client turns both into a projection an application can trust across a page hide, a phone lock or a network change. A native `EventSource` cannot set a request header, cannot tell a silent stream from an idle one, and reconnects on its own schedule with whatever `Last-Event-ID` it last saw; this client owns the connection over `fetch` and `ReadableStream` instead, presents the cursor it holds, measures liveness on bytes, closes a hidden tab's stream after a minute and reopens it on return, backs off with full jitter, and holds incoming frames while the application asks the server what changed. It has no runtime dependencies, and the DOM readers (`document.visibilityState`, `navigator.onLine`) are injectable, so the same runtime runs in Node against the Go test fixture.
|
|
5
|
+
The TypeScript half of [github.com/cplieger/go/sse](https://github.com/cplieger/go/tree/main/sse), speaking the wire the Go `sse` package serves: every frame carries an `epoch:offset` cursor, every connection opens with a hello that says whether that cursor was honoured, and the client turns both into a projection an application can trust across a page hide, a phone lock or a network change. A native `EventSource` cannot set a request header, cannot tell a silent stream from an idle one, and reconnects on its own schedule with whatever `Last-Event-ID` it last saw; this client owns the connection over `fetch` and `ReadableStream` instead, presents the cursor it holds, measures liveness on bytes, closes a hidden tab's stream after a minute and reopens it on return, backs off with full jitter, and holds incoming frames while the application asks the server what changed. It has no runtime dependencies, and the DOM readers (`document.visibilityState`, `navigator.onLine`) are injectable, so the same runtime runs in Node against the Go test fixture.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -48,6 +48,8 @@ While `revalidate` runs, incoming frames are held and delivered in order once it
|
|
|
48
48
|
|
|
49
49
|
With `alive` set, every keepalive (or every `everyBeats`-th) is acknowledged with a `POST` to that URL carrying the stream's headers, so a server-side presence table keyed on `SSE-Client` sees which clients are still reading; a failed acknowledgement is reported as an `alive_ack` record.
|
|
50
50
|
|
|
51
|
+
A 401 ends the session's stream instead of retrying it: a 401 on the stream request, on an alive acknowledgement, or from a `revalidate` that rejects with `{ status: 401 }` stops the stream and emits an `auth_lost` record whose `route` is `stream`, `alive` or `digest`. A tab's own stream and the worker host behave alike, and the host passes the record to every tab. Any other status backs off and reconnects.
|
|
52
|
+
|
|
51
53
|
`createWorkerHost` runs one stream per browser profile inside a `SharedWorker`, and `attachToWorker` connects each tab to it over a `MessagePort`, folding the tabs' visibility and network readings into that one connection; where `SharedWorker` is absent or the worker never answers, the tab falls back to its own stream and reports the switch. The `fallback` option returns a `TabFallback`: the `createStream` result together with the `versions` map and the `headers` record it was built on, which is what lets the `TabAttachment` work the same in both modes. `observe(subject, version, epoch)` reports a stamp the tab applied, into the host's version map or the fallback's; `reconnect({ resetCursor })` reconnects the stream the tab is attached to; `setTag(tag)` presents a new `SSE-Client` value with one reconnect, written into the host's headers or the fallback's; and `detach("logout")` ends the profile's stream. The tab writes its `tag` into the fallback's headers before the first connect, so a consumer never fills `SSE-Client` itself.
|
|
52
54
|
|
|
53
55
|
The host presents the first non-empty tag a tab attaches with and replaces it, reconnecting once, when a later attach or `setTag` carries a different one; the empty tag is ignored.
|
|
@@ -64,7 +66,7 @@ The package typechecks under `lib: ["ESNext", "WebWorker"]` as well as under the
|
|
|
64
66
|
|
|
65
67
|
## Full documentation
|
|
66
68
|
|
|
67
|
-
The Go server API is in the [repository README](https://github.com/cplieger/sse#readme). The hub options and the presence hook are in [Running the hub](https://github.com/cplieger/
|
|
69
|
+
The Go server API is in the [repository README](https://github.com/cplieger/go/tree/main/sse#readme). The hub options and the presence hook are in [Running the hub](https://github.com/cplieger/go/blob/main/sse/docs/hub.md), and the hello's verdicts and the digest are in [The wire contract](https://github.com/cplieger/go/blob/main/sse/docs/wire.md). The shared timing constants both halves pin are in [`timing.json`](https://github.com/cplieger/go/blob/main/sse/timing.json).
|
|
68
70
|
|
|
69
71
|
## Disclaimer
|
|
70
72
|
|
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/sse",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0-dev.4",
|
|
4
4
|
"description": "Server-Sent Events hub and client that resume from an exact cursor and reconcile after sleep",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/cplieger/
|
|
11
|
-
"directory": "web"
|
|
10
|
+
"url": "git+https://github.com/cplieger/go.git",
|
|
11
|
+
"directory": "sse/web"
|
|
12
12
|
},
|
|
13
|
-
"homepage": "https://github.com/cplieger/sse",
|
|
13
|
+
"homepage": "https://github.com/cplieger/go/tree/main/sse",
|
|
14
14
|
"bugs": {
|
|
15
|
-
"url": "https://github.com/cplieger/
|
|
15
|
+
"url": "https://github.com/cplieger/go/issues"
|
|
16
16
|
},
|
|
17
17
|
"author": "cplieger <917744+cplieger@users.noreply.github.com>",
|
|
18
18
|
"keywords": [
|
|
@@ -38,11 +38,6 @@
|
|
|
38
38
|
"LICENSE",
|
|
39
39
|
"NOTICE"
|
|
40
40
|
],
|
|
41
|
-
"sseFixture": "./ssetest/cmd",
|
|
42
|
-
"browserEngines": [
|
|
43
|
-
"firefox",
|
|
44
|
-
"webkit"
|
|
45
|
-
],
|
|
46
41
|
"scripts": {
|
|
47
42
|
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.worker.json && tsc -p tsconfig.tests.json",
|
|
48
43
|
"typecheck:tests": "tsc -p tsconfig.tests.json",
|
|
@@ -55,25 +50,26 @@
|
|
|
55
50
|
},
|
|
56
51
|
"devDependencies": {
|
|
57
52
|
"@eslint/js": "10.0.1",
|
|
58
|
-
"@stryker-mutator/core": "10.
|
|
59
|
-
"@stryker-mutator/typescript-checker": "10.
|
|
60
|
-
"@stryker-mutator/vitest-runner": "10.
|
|
61
|
-
"@types/node": "26.6.
|
|
53
|
+
"@stryker-mutator/core": "10.1.1",
|
|
54
|
+
"@stryker-mutator/typescript-checker": "10.1.1",
|
|
55
|
+
"@stryker-mutator/vitest-runner": "10.1.1",
|
|
56
|
+
"@types/node": "26.6.6",
|
|
62
57
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
63
58
|
"@vitest/browser": "5.0.5",
|
|
64
59
|
"@vitest/browser-playwright": "5.0.5",
|
|
65
60
|
"@vitest/coverage-v8": "5.0.5",
|
|
66
|
-
"eslint": "10.
|
|
67
|
-
"eventsource-parser": "4.1.
|
|
61
|
+
"eslint": "10.14.0",
|
|
62
|
+
"eventsource-parser": "4.1.3",
|
|
68
63
|
"fast-check": "4.10.2",
|
|
69
|
-
"knip": "6.
|
|
64
|
+
"knip": "6.44.1",
|
|
70
65
|
"playwright": "1.64.1",
|
|
71
66
|
"prettier": "3.9.10",
|
|
72
67
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
73
|
-
"typescript-eslint": "8.
|
|
68
|
+
"typescript-eslint": "8.72.1",
|
|
74
69
|
"vitest": "5.0.5"
|
|
75
70
|
},
|
|
76
71
|
"overrides": {
|
|
77
|
-
"qs": "^6.15.2"
|
|
72
|
+
"qs": "^6.15.2",
|
|
73
|
+
"minimatch": "^11.0.1"
|
|
78
74
|
}
|
|
79
75
|
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -54,7 +54,7 @@ export type LifecycleEvent =
|
|
|
54
54
|
| { kind: "worker_dead"; sinceLastPortMessageMs: number }
|
|
55
55
|
| { kind: "worker_unavailable"; cause: "silent" | "error" }
|
|
56
56
|
| { kind: "worker_recovered" }
|
|
57
|
-
| { kind: "auth_lost"; route:
|
|
57
|
+
| { kind: "auth_lost"; route: "stream" | "alive" | "digest"; status: 401 }
|
|
58
58
|
| { kind: "tab_attached"; replaced: boolean; state: ClientState["kind"] }
|
|
59
59
|
| { kind: "tab_detached"; cause: string }
|
|
60
60
|
| { kind: "port_expired"; sinceLastAckMs: number }
|
package/src/port-protocol.ts
CHANGED
|
@@ -55,7 +55,13 @@ export type TabToWorker =
|
|
|
55
55
|
| { readonly type: "reconnect"; readonly resetCursor: boolean }
|
|
56
56
|
| { readonly type: "set_tag"; readonly tag: string }
|
|
57
57
|
| { readonly type: "revalidate_done"; readonly runId: number }
|
|
58
|
-
| {
|
|
58
|
+
| {
|
|
59
|
+
readonly type: "revalidate_failed";
|
|
60
|
+
readonly runId: number;
|
|
61
|
+
readonly cause: string;
|
|
62
|
+
/** The rejection's numeric `status`, absent when it carried none. */
|
|
63
|
+
readonly status?: number;
|
|
64
|
+
}
|
|
59
65
|
| { readonly type: "frame_failed"; readonly generation: number; readonly cursor: Cursor | null };
|
|
60
66
|
|
|
61
67
|
/**
|
package/src/stream.ts
CHANGED
|
@@ -150,6 +150,15 @@ function transportGeneration(event: ClientEvent): number | null {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** The numeric `status` an error carries, or null. */
|
|
154
|
+
export function statusOf(error: unknown): number | null {
|
|
155
|
+
if (typeof error !== "object" || error === null) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const status = (error as { status?: unknown }).status;
|
|
159
|
+
return typeof status === "number" ? status : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
153
162
|
function describe(error: unknown): string {
|
|
154
163
|
if (error instanceof Error) {
|
|
155
164
|
return error.message;
|
|
@@ -157,7 +166,12 @@ function describe(error: unknown): string {
|
|
|
157
166
|
return String(error);
|
|
158
167
|
}
|
|
159
168
|
|
|
160
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Creates the client runtime. Throws RangeError when `timing.maxBufferBytes` is below MAX_FRAME_BYTES.
|
|
171
|
+
* A 401 stops the stream and emits `auth_lost`, with route `stream` for the stream request, `alive`
|
|
172
|
+
* for the alive acknowledgement and `digest` for a revalidate rejecting with `{status: 401}`; any
|
|
173
|
+
* other status backs off, or fails the run.
|
|
174
|
+
*/
|
|
161
175
|
export function createStream(opts: StreamOptions): Stream {
|
|
162
176
|
const cfg = resolveTiming(opts.timing);
|
|
163
177
|
const doFetch: typeof fetch = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
@@ -200,6 +214,11 @@ export function createStream(opts: StreamOptions): Stream {
|
|
|
200
214
|
opts.onLifecycle?.(event);
|
|
201
215
|
}
|
|
202
216
|
|
|
217
|
+
function authLost(route: "stream" | "alive" | "digest"): void {
|
|
218
|
+
stream.stop();
|
|
219
|
+
emit({ kind: "auth_lost", route, status: 401 });
|
|
220
|
+
}
|
|
221
|
+
|
|
203
222
|
bindListener(opts.versions, (epoch) => {
|
|
204
223
|
const previous = knownEpoch;
|
|
205
224
|
knownEpoch = epoch;
|
|
@@ -323,6 +342,10 @@ export function createStream(opts: StreamOptions): Stream {
|
|
|
323
342
|
onConnectFailed(g, reason) {
|
|
324
343
|
if (g === state.generation) {
|
|
325
344
|
emit({ kind: "connect_failed", reason });
|
|
345
|
+
if (reason.kind === "status" && reason.status === 401) {
|
|
346
|
+
authLost("stream");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
326
349
|
}
|
|
327
350
|
dispatch({ type: "connect_failed", generation: g, now: now(), reason, rand: rand() });
|
|
328
351
|
},
|
|
@@ -579,6 +602,9 @@ export function createStream(opts: StreamOptions): Stream {
|
|
|
579
602
|
.then(
|
|
580
603
|
(response) => {
|
|
581
604
|
emit({ kind: "alive_ack", ok: response.ok, status: response.status });
|
|
605
|
+
if (response.status === 401 && state.kind !== "stopped") {
|
|
606
|
+
authLost("alive");
|
|
607
|
+
}
|
|
582
608
|
},
|
|
583
609
|
() => {
|
|
584
610
|
if (!scope.signal.aborted) {
|
|
@@ -644,6 +670,10 @@ export function createStream(opts: StreamOptions): Stream {
|
|
|
644
670
|
if (inflight !== current) {
|
|
645
671
|
return;
|
|
646
672
|
}
|
|
673
|
+
if (rejected && statusOf(error) === 401) {
|
|
674
|
+
authLost("digest");
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
647
677
|
if (current.timer !== null) {
|
|
648
678
|
clearTimeout(current.timer);
|
|
649
679
|
}
|
package/src/tab.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
isWorkerMessage,
|
|
8
8
|
} from "./port-protocol.js";
|
|
9
9
|
import type { ClientState } from "./reducer.js";
|
|
10
|
-
import type
|
|
10
|
+
import { type Frame, type Stream, statusOf } from "./stream.js";
|
|
11
11
|
import { DEFAULT_TIMING } from "./timing.js";
|
|
12
12
|
import type { Subject, VersionMap } from "./versions.js";
|
|
13
13
|
import {
|
|
@@ -229,10 +229,12 @@ export function attachToWorker(opts: AttachOptions): TabAttachment {
|
|
|
229
229
|
reply({ type: "revalidate_done", runId });
|
|
230
230
|
},
|
|
231
231
|
(e: unknown) => {
|
|
232
|
+
const status = statusOf(e);
|
|
232
233
|
reply({
|
|
233
234
|
type: "revalidate_failed",
|
|
234
235
|
runId,
|
|
235
236
|
cause: e instanceof Error ? e.message : String(e),
|
|
237
|
+
...(status === null ? {} : { status }),
|
|
236
238
|
});
|
|
237
239
|
},
|
|
238
240
|
)
|
package/src/timing.ts
CHANGED
|
@@ -60,7 +60,7 @@ export interface TimingConfig {
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* The value every field a caller leaves unset takes. Each one is pinned, directly or by derivation,
|
|
63
|
-
* to the
|
|
63
|
+
* to the module's `timing.json` — the single source both halves of the protocol read — so a
|
|
64
64
|
* client default and the server behaviour it assumes cannot drift apart.
|
|
65
65
|
*/
|
|
66
66
|
export const DEFAULT_TIMING: TimingConfig = {
|
package/src/worker.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface TabSet {
|
|
|
29
29
|
/**
|
|
30
30
|
* Fans the run to every acknowledging tab; settles when each answered, expired or left.
|
|
31
31
|
* With `verdict`, each tab's context carries `changed` and `removed`, so one digest serves them all.
|
|
32
|
+
* A failed run rejects with a tab's cause and its `status`, a 401 first, so a revalidate that
|
|
33
|
+
* rethrows it reaches `auth_lost` route `digest`.
|
|
32
34
|
*/
|
|
33
35
|
run(ctx: RevalidateContext, verdict?: DigestVerdict): Promise<void>;
|
|
34
36
|
size(): number;
|
|
@@ -56,8 +58,9 @@ export interface WorkerHostOptions extends Omit<
|
|
|
56
58
|
/**
|
|
57
59
|
* The profile's stream owner: one stream, one cursor and one version map behind however many tabs.
|
|
58
60
|
* The stream starts when the first tab attaches, and it is stopped by `close()`, by a `logout` detach
|
|
59
|
-
* or by a 401
|
|
60
|
-
* owner documents and a tab that
|
|
61
|
+
* or by a 401 (createStream's `auth_lost`, which every tab receives), not by the last port going
|
|
62
|
+
* away, since the browser keeps a SharedWorker alive for its owner documents and a tab that
|
|
63
|
+
* navigates is expected back.
|
|
61
64
|
*/
|
|
62
65
|
export interface WorkerHost {
|
|
63
66
|
/** Called from the SharedWorker's onconnect with each new port. */
|
|
@@ -79,21 +82,13 @@ interface PortEntry {
|
|
|
79
82
|
|
|
80
83
|
interface Quorum {
|
|
81
84
|
readonly pending: Set<PortEntry>;
|
|
82
|
-
readonly failures: string[];
|
|
85
|
+
readonly failures: { readonly cause: string; readonly status: number | null }[];
|
|
83
86
|
readonly resolve: () => void;
|
|
84
87
|
readonly reject: (error: Error) => void;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
const EXPIRY_BEATS = 3;
|
|
88
91
|
|
|
89
|
-
function statusOf(error: unknown): number | null {
|
|
90
|
-
if (typeof error !== "object" || error === null) {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
const status = (error as { status?: unknown }).status;
|
|
94
|
-
return typeof status === "number" ? status : null;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
92
|
function cloneSafe(event: LifecycleEvent): LifecycleEvent {
|
|
98
93
|
if (event.kind === "frame_rejected") {
|
|
99
94
|
return {
|
|
@@ -198,11 +193,13 @@ export function createWorkerHost(opts: WorkerHostOptions): WorkerHost {
|
|
|
198
193
|
return;
|
|
199
194
|
}
|
|
200
195
|
quorums.delete(runId);
|
|
201
|
-
const failure = quorum.failures[0];
|
|
196
|
+
const failure = quorum.failures.find((f) => f.status === 401) ?? quorum.failures[0];
|
|
202
197
|
if (failure === undefined) {
|
|
203
198
|
quorum.resolve();
|
|
199
|
+
} else if (failure.status === null) {
|
|
200
|
+
quorum.reject(new Error(failure.cause));
|
|
204
201
|
} else {
|
|
205
|
-
quorum.reject(new Error(failure));
|
|
202
|
+
quorum.reject(Object.assign(new Error(failure.cause), { status: failure.status }));
|
|
206
203
|
}
|
|
207
204
|
}
|
|
208
205
|
|
|
@@ -214,11 +211,6 @@ export function createWorkerHost(opts: WorkerHostOptions): WorkerHost {
|
|
|
214
211
|
}
|
|
215
212
|
}
|
|
216
213
|
|
|
217
|
-
function authLost(route: string, status: number): void {
|
|
218
|
-
stream.stop();
|
|
219
|
-
emit({ kind: "auth_lost", route, status });
|
|
220
|
-
}
|
|
221
|
-
|
|
222
214
|
const stream = createStream({
|
|
223
215
|
...opts,
|
|
224
216
|
headers,
|
|
@@ -240,20 +232,8 @@ export function createWorkerHost(opts: WorkerHostOptions): WorkerHost {
|
|
|
240
232
|
broadcast({ type: "state", state: stream.state() });
|
|
241
233
|
}
|
|
242
234
|
emit(event);
|
|
243
|
-
if (event.kind === "alive_ack" && event.status === 401) {
|
|
244
|
-
authLost("alive", 401);
|
|
245
|
-
}
|
|
246
|
-
},
|
|
247
|
-
async revalidate(ctx) {
|
|
248
|
-
try {
|
|
249
|
-
await opts.revalidate(ctx, tabs);
|
|
250
|
-
} catch (e: unknown) {
|
|
251
|
-
if (statusOf(e) === 401) {
|
|
252
|
-
authLost("digest", 401);
|
|
253
|
-
}
|
|
254
|
-
throw e;
|
|
255
|
-
}
|
|
256
235
|
},
|
|
236
|
+
revalidate: (ctx) => opts.revalidate(ctx, tabs),
|
|
257
237
|
});
|
|
258
238
|
const controls = streamInternals(stream);
|
|
259
239
|
|
|
@@ -420,7 +400,10 @@ export function createWorkerHost(opts: WorkerHostOptions): WorkerHost {
|
|
|
420
400
|
return;
|
|
421
401
|
}
|
|
422
402
|
if (message.type === "revalidate_failed") {
|
|
423
|
-
quorum.failures.push(
|
|
403
|
+
quorum.failures.push({
|
|
404
|
+
cause: message.cause,
|
|
405
|
+
status: typeof message.status === "number" ? message.status : null,
|
|
406
|
+
});
|
|
424
407
|
}
|
|
425
408
|
settleQuorum(message.runId, quorum);
|
|
426
409
|
return;
|