@cplieger/sse 1.0.0 → 1.1.2-dev.1
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/LICENSE +0 -0
- package/NOTICE +3 -0
- package/README.md +1 -1
- package/jsr.json +14 -3
- package/package.json +21 -13
- package/src/digest.ts +31 -0
- package/src/fold.ts +38 -0
- package/src/lifecycle.ts +13 -0
- package/src/online.ts +11 -0
- package/src/parser.ts +16 -0
- package/src/port-protocol.ts +22 -0
- package/src/reducer.ts +34 -0
- package/src/stream.ts +28 -0
- package/src/tab.ts +16 -0
- package/src/timing.ts +11 -1
- package/src/transport.ts +12 -0
- package/src/versions.ts +6 -0
- package/src/visibility.ts +16 -0
- package/src/wire.ts +10 -1
- package/src/worker.ts +13 -0
package/LICENSE
CHANGED
|
File without changes
|
package/NOTICE
ADDED
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ The package typechecks under `lib: ["ESNext", "WebWorker"]` as well as under the
|
|
|
64
64
|
|
|
65
65
|
## Full documentation
|
|
66
66
|
|
|
67
|
-
The Go server API
|
|
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/sse/blob/main/docs/hub.md), and the hello's verdicts and the digest are in [The wire contract](https://github.com/cplieger/sse/blob/main/docs/wire.md). The shared timing constants both halves pin are in the repository's `timing.json`.
|
|
68
68
|
|
|
69
69
|
## Disclaimer
|
|
70
70
|
|
package/jsr.json
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/sse",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.2-dev.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts"
|
|
7
7
|
},
|
|
8
8
|
"publish": {
|
|
9
|
-
"include": [
|
|
10
|
-
|
|
9
|
+
"include": [
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"NOTICE",
|
|
12
|
+
"README.md",
|
|
13
|
+
"THIRD_PARTY_NOTICES.md",
|
|
14
|
+
"src/**/*.ts",
|
|
15
|
+
"jsr.json"
|
|
16
|
+
],
|
|
17
|
+
"exclude": [
|
|
18
|
+
"src/**/*.test.ts",
|
|
19
|
+
"src/**/*-setup.ts",
|
|
20
|
+
"src/**/test-helpers/**"
|
|
21
|
+
]
|
|
11
22
|
}
|
|
12
23
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/sse",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.2-dev.1",
|
|
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",
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
"!src/**/test-helpers/**",
|
|
35
35
|
"!src/**/*.golden.json",
|
|
36
36
|
"jsr.json",
|
|
37
|
-
"THIRD_PARTY_NOTICES.md"
|
|
37
|
+
"THIRD_PARTY_NOTICES.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
38
40
|
],
|
|
39
41
|
"sseFixture": "./ssetest/cmd",
|
|
40
42
|
"browserEngines": [
|
|
@@ -47,23 +49,29 @@
|
|
|
47
49
|
"test": "vitest --run",
|
|
48
50
|
"test:browser": "vitest --run --project browser",
|
|
49
51
|
"lint:eslint": "eslint .",
|
|
50
|
-
"lint:prettier": "prettier --check ."
|
|
52
|
+
"lint:prettier": "prettier --check .",
|
|
53
|
+
"lint:knip": "knip",
|
|
54
|
+
"lint:fix": "eslint . --fix && prettier --write ."
|
|
51
55
|
},
|
|
52
56
|
"devDependencies": {
|
|
53
57
|
"@eslint/js": "10.0.1",
|
|
54
|
-
"@
|
|
58
|
+
"@stryker-mutator/core": "10.0.0",
|
|
59
|
+
"@stryker-mutator/typescript-checker": "10.0.0",
|
|
60
|
+
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
61
|
+
"@types/node": "26.6.4",
|
|
55
62
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
56
|
-
"@vitest/browser": "5.0.
|
|
57
|
-
"@vitest/browser-playwright": "5.0.
|
|
58
|
-
"@vitest/coverage-v8": "5.0.
|
|
59
|
-
"eslint": "10.
|
|
60
|
-
"eventsource-parser": "4.1.
|
|
61
|
-
"fast-check": "4.
|
|
63
|
+
"@vitest/browser": "5.0.3",
|
|
64
|
+
"@vitest/browser-playwright": "5.0.3",
|
|
65
|
+
"@vitest/coverage-v8": "5.0.3",
|
|
66
|
+
"eslint": "10.12.0",
|
|
67
|
+
"eventsource-parser": "4.1.1",
|
|
68
|
+
"fast-check": "4.10.2",
|
|
69
|
+
"knip": "6.39.0",
|
|
62
70
|
"playwright": "1.63.0",
|
|
63
|
-
"prettier": "3.9.
|
|
71
|
+
"prettier": "3.9.9",
|
|
64
72
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
65
|
-
"typescript-eslint": "8.
|
|
66
|
-
"vitest": "5.0.
|
|
73
|
+
"typescript-eslint": "8.71.0",
|
|
74
|
+
"vitest": "5.0.3"
|
|
67
75
|
},
|
|
68
76
|
"overrides": {
|
|
69
77
|
"qs": "^6.15.2"
|
package/src/digest.ts
CHANGED
|
@@ -10,20 +10,51 @@ export interface Removed extends Subject {
|
|
|
10
10
|
readonly reason: "gone" | "forbidden";
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The answer to one check, always positioned at the epoch and head the server held when it
|
|
15
|
+
* answered. `must_refetch` means no comparison happened and the application must refetch rather
|
|
16
|
+
* than apply a diff: the presented epoch was absent or not the server's, its resolver failed, or
|
|
17
|
+
* the answer did not match the batch that was asked about. `ok` names the subjects whose version
|
|
18
|
+
* moved and the ones the server no longer answers for; a subject in neither list is unchanged.
|
|
19
|
+
*/
|
|
13
20
|
export type DigestResult =
|
|
14
21
|
| { kind: "must_refetch"; epoch: string; head: string }
|
|
15
22
|
| { kind: "ok"; epoch: string; head: string; changed: State[]; removed: Removed[] };
|
|
16
23
|
|
|
24
|
+
/**
|
|
25
|
+
* What one check asks about: the epoch the held versions were minted under, and the versions
|
|
26
|
+
* themselves. `VersionMap.snapshot()` produces one directly. A null epoch presents none, which the
|
|
27
|
+
* server answers with must_refetch for any non-empty snapshot, so an unbound map is worth checking
|
|
28
|
+
* only when it is also empty.
|
|
29
|
+
*/
|
|
17
30
|
export interface DigestSnapshot {
|
|
18
31
|
readonly epoch: string | null;
|
|
19
32
|
readonly held: readonly Held[];
|
|
20
33
|
}
|
|
21
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The client of the digest route, and nothing more: it reads no version map and writes to none, so
|
|
37
|
+
* applying its answer is the caller's step. `check` splits the snapshot into POSTs of at most
|
|
38
|
+
* `maxSubjects` and stops at the first must_refetch, since a refusal makes every remaining batch
|
|
39
|
+
* moot. It rejects with DigestStatusError on any status but 200, with a TimeoutError DOMException
|
|
40
|
+
* when a POST's request phase outlives `timeoutMs`, and with a plain Error when a 200 body is not
|
|
41
|
+
* a positioned digest answer.
|
|
42
|
+
*/
|
|
22
43
|
export interface DigestClient {
|
|
23
44
|
/** Asks the server which held versions moved; the optional signal cancels every POST. */
|
|
24
45
|
check(snapshot: DigestSnapshot, signal?: AbortSignal): Promise<DigestResult>;
|
|
25
46
|
}
|
|
26
47
|
|
|
48
|
+
/**
|
|
49
|
+
* How the client reaches the route. `url` is POSTed with `credentials: "same-origin"`; `fetch`
|
|
50
|
+
* defaults to globalThis.fetch and is where a consumer merges its own credentials or bearer
|
|
51
|
+
* header; `headers` is merged first, so it cannot displace the Accept and Content-Type the route
|
|
52
|
+
* requires. `timeoutMs` defaults to digestTimeoutMs and bounds one POST's request and the wait for
|
|
53
|
+
* its response headers, not the whole check and not the body read: it is released as soon as the
|
|
54
|
+
* response object arrives, so the body read is bounded only by the signal passed to `check`, and
|
|
55
|
+
* by nothing at all when that argument is omitted.
|
|
56
|
+
* `maxSubjects` defaults to DIGEST_MAX_SUBJECTS, the cap the server enforces at its own default.
|
|
57
|
+
*/
|
|
27
58
|
export interface DigestClientOptions {
|
|
28
59
|
readonly url: string;
|
|
29
60
|
readonly fetch?: typeof fetch;
|
package/src/fold.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { OnlineSource } from "./online.js";
|
|
2
2
|
import type { VisibilityEvent, VisibilityInput, VisibilitySource } from "./visibility.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* One attached tab's contribution to the profile: the two readings it forwards. A tab is in the fold
|
|
6
|
+
* from its attach until it is removed or reports `pagehide`. Its visibility stops counting the moment
|
|
7
|
+
* it leaves; the profile's network reading is not recomputed on a departure and keeps the last value
|
|
8
|
+
* reported, by whichever tab.
|
|
9
|
+
*/
|
|
4
10
|
export interface TabState {
|
|
5
11
|
readonly visible: boolean;
|
|
6
12
|
readonly online: boolean;
|
|
@@ -8,6 +14,12 @@ export interface TabState {
|
|
|
8
14
|
readonly pageshowPending: boolean;
|
|
9
15
|
}
|
|
10
16
|
|
|
17
|
+
/**
|
|
18
|
+
* One tab's report into the fold, always keyed by `tabId`. An `attach` seeds that tab's two readings,
|
|
19
|
+
* and `visibility` and `network` update them afterwards. A `visibility` report for a tab the fold does
|
|
20
|
+
* not know is dropped; a `network` report from one still counts as the profile's latest reading, since
|
|
21
|
+
* the network is a property of the machine rather than of the tab that noticed.
|
|
22
|
+
*/
|
|
11
23
|
export type FoldInput =
|
|
12
24
|
| {
|
|
13
25
|
readonly type: "attach";
|
|
@@ -20,8 +32,17 @@ export type FoldInput =
|
|
|
20
32
|
/** Detach, port close, expiry: the tab leaves as if by pagehide. */
|
|
21
33
|
| { readonly type: "remove"; readonly tabId: string };
|
|
22
34
|
|
|
35
|
+
/**
|
|
36
|
+
* What a fold step emits for the profile's one stream: a transition, never a tab's report. A step that
|
|
37
|
+
* changes nothing the stream can act on emits nothing — a tab going hidden while another stays visible
|
|
38
|
+
* is silent — and `pageshow` is emitted at most once per visible period however many tabs are restored.
|
|
39
|
+
*/
|
|
23
40
|
export type FoldOutput = "visible" | "hidden" | "pageshow" | "online" | "offline";
|
|
24
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The profile's folded view of its tabs. Every step returns a new value rather than mutating this one,
|
|
44
|
+
* so a caller may hold an earlier state and compare against it.
|
|
45
|
+
*/
|
|
25
46
|
export interface FoldState {
|
|
26
47
|
readonly tabs: ReadonlyMap<string, TabState>;
|
|
27
48
|
/** The latest network report from any tab, seeded by the host. */
|
|
@@ -30,6 +51,11 @@ export interface FoldState {
|
|
|
30
51
|
readonly pageshown: boolean;
|
|
31
52
|
}
|
|
32
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The fold before any tab has attached. `online` is the host's own reading, which stands as the
|
|
56
|
+
* profile's until a tab reports one, so a host that seeds it wrong holds the stream off the network
|
|
57
|
+
* until the first tab corrects it.
|
|
58
|
+
*/
|
|
33
59
|
export function createFoldState(online: boolean): FoldState {
|
|
34
60
|
return { tabs: new Map(), online, pageshown: false };
|
|
35
61
|
}
|
|
@@ -147,6 +173,12 @@ export interface ProfileVisibilitySource extends VisibilitySource {
|
|
|
147
173
|
report(tabId: string, ev: VisibilityInput): void;
|
|
148
174
|
}
|
|
149
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Creates the visibility reading the worker host gives its stream: the fold over the tabs it has, fed
|
|
178
|
+
* by attach, detach and report rather than by any platform event. It emits only the profile's own
|
|
179
|
+
* transitions, so the stream sees one `visible` for a whole set of tabs coming back and one `hidden`
|
|
180
|
+
* only once none is left showing.
|
|
181
|
+
*/
|
|
150
182
|
export function createProfileVisibilitySource(): ProfileVisibilitySource {
|
|
151
183
|
let state = createFoldState(true);
|
|
152
184
|
const out = listenable<VisibilityEvent>();
|
|
@@ -188,6 +220,12 @@ export interface ProfileOnlineSource extends OnlineSource {
|
|
|
188
220
|
report(tabId: string, online: boolean): void;
|
|
189
221
|
}
|
|
190
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Creates the network reading the worker host gives its stream: the last value any tab reported,
|
|
225
|
+
* starting at `seed`. The tabs are the source because createDOMOnlineSource registers nothing inside a
|
|
226
|
+
* worker — there is no `window` there to hear `online` and `offline` on — so a host reading the DOM
|
|
227
|
+
* directly would never be handed a transition to act on.
|
|
228
|
+
*/
|
|
191
229
|
export function createProfileOnlineSource(seed: boolean): ProfileOnlineSource {
|
|
192
230
|
let state = createFoldState(seed);
|
|
193
231
|
const out = listenable<boolean>();
|
package/src/lifecycle.ts
CHANGED
|
@@ -2,8 +2,21 @@ import type { ClientState, ConnectFailure } from "./reducer.js";
|
|
|
2
2
|
import type { Subject } from "./versions.js";
|
|
3
3
|
import type { ResetReason } from "./wire.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Why a revalidation was scheduled. The first three are the platform waking the client up; `hello`
|
|
7
|
+
* is every run the runtime demands for itself — a hello that did not resume, a version map the
|
|
8
|
+
* hello cleared, a frame the application threw on, and the connect after a run that failed. A
|
|
9
|
+
* `hello` run never coalesces with a neighbouring one, and neither does anything arriving while
|
|
10
|
+
* one is in flight.
|
|
11
|
+
*/
|
|
5
12
|
export type RevalidateCause = "visible" | "pageshow" | "online" | "hello";
|
|
6
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Why frames held for a run were dropped instead of delivered: the attempt was aborted, the stream
|
|
16
|
+
* ended under it, the run passed revalidateTimeoutMs, the hold passed its frame or byte bound, or
|
|
17
|
+
* the run rejected. A held frame never advanced the cursor, so the next connect asks for the
|
|
18
|
+
* discarded ones again.
|
|
19
|
+
*/
|
|
7
20
|
export type HeldDiscardCause =
|
|
8
21
|
"abort" | "stream_ended" | "hold_timeout" | "hold_overflow" | "revalidate_failed";
|
|
9
22
|
|
package/src/online.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The network reading, injected rather than assumed. A true is only ever a reason to try — no
|
|
3
|
+
* platform can promise a route — while a false is taken at its word and holds the client off the
|
|
4
|
+
* network, so a source that guesses false is the expensive mistake. `listen` returns the unsubscribe.
|
|
5
|
+
*/
|
|
1
6
|
export interface OnlineSource {
|
|
2
7
|
online(): boolean;
|
|
3
8
|
listen(cb: (online: boolean) => void): () => void;
|
|
4
9
|
}
|
|
5
10
|
|
|
11
|
+
/**
|
|
12
|
+
* One network reading shared by every subscriber, listening to the source only while at least one
|
|
13
|
+
* subscriber is attached. createWorkerHost does not read the DOM source through this: it folds the
|
|
14
|
+
* readings its tabs forward, because createDOMOnlineSource registers nothing where there is no
|
|
15
|
+
* `window` to hear `online` and `offline` on.
|
|
16
|
+
*/
|
|
6
17
|
export interface OnlineManager {
|
|
7
18
|
isOnline(): boolean;
|
|
8
19
|
/** The source is listened to from the first subscriber to the last. */
|
package/src/parser.ts
CHANGED
|
@@ -10,6 +10,12 @@ export interface ParsedEvent {
|
|
|
10
10
|
readonly bytes: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* What the parser reports. `onEvent` fires once per dispatched frame, which per WHATWG means a
|
|
15
|
+
* frame carrying no `data` field dispatches nothing at all — its `id` is still remembered as
|
|
16
|
+
* the sticky last event id. The other three are optional and each fires for one wire feature:
|
|
17
|
+
* a well-formed `retry:` value, a comment line, and the buffer cap being passed.
|
|
18
|
+
*/
|
|
13
19
|
export interface ParserHandlers {
|
|
14
20
|
readonly onEvent: (event: ParsedEvent) => void;
|
|
15
21
|
readonly onRetry?: (ms: number) => void;
|
|
@@ -18,10 +24,20 @@ export interface ParserHandlers {
|
|
|
18
24
|
readonly onOverflow?: (bytes: number) => void;
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The parser's only bound: the encoded bytes one frame may occupy, counted from the end of the
|
|
29
|
+
* previous frame and including its own terminating blank line. It must be at least
|
|
30
|
+
* MAX_FRAME_BYTES, or a frame the server is entitled to send would abort every connection.
|
|
31
|
+
*/
|
|
21
32
|
export interface ParserOptions {
|
|
22
33
|
readonly maxBufferBytes: number;
|
|
23
34
|
}
|
|
24
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A byte-fed event-stream parser. Feeding is incremental: a chunk may split a line, a CRLF pair
|
|
38
|
+
* or a multi-byte character, and the parse resumes from the next chunk. Once a frame passes
|
|
39
|
+
* maxBufferBytes the parser latches off — every further feed is ignored until reset().
|
|
40
|
+
*/
|
|
25
41
|
export interface Parser {
|
|
26
42
|
feed(chunk: Uint8Array): void;
|
|
27
43
|
/** Returns the parser to its construction state; a partial event is discarded. */
|
package/src/port-protocol.ts
CHANGED
|
@@ -6,6 +6,12 @@ import type { Subject } from "./versions.js";
|
|
|
6
6
|
import type { VisibilityInput } from "./visibility.js";
|
|
7
7
|
import type { Cursor } from "./wire.js";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Why a tab is leaving the host. `unload` and `logout` are the application's, through
|
|
11
|
+
* `TabAttachment.detach`; `respawn` and `fallback` are the tab's own ladder giving up on a worker that
|
|
12
|
+
* stopped answering. Only `logout` stops the profile's stream — the other three leave it running for
|
|
13
|
+
* whatever tabs remain.
|
|
14
|
+
*/
|
|
9
15
|
export type DetachCause = "unload" | "respawn" | "fallback" | "logout";
|
|
10
16
|
|
|
11
17
|
/** RevalidateContext without its signal, which cannot cross a port; the tab supplies its own. */
|
|
@@ -19,6 +25,13 @@ export interface PortRevalidateContext {
|
|
|
19
25
|
readonly removed?: readonly Removed[];
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Every message a tab sends the host. `attach` comes first and identifies the tab: until the host has
|
|
30
|
+
* one, only `ping` is answered and everything else is discarded. Its `hadWorker` says this tab held a
|
|
31
|
+
* worker link before this attach, which is what earns it a full revalidation where a first attach to a
|
|
32
|
+
* live stream is given none — the state it holds was built against a stream it can no longer account
|
|
33
|
+
* for.
|
|
34
|
+
*/
|
|
22
35
|
export type TabToWorker =
|
|
23
36
|
| {
|
|
24
37
|
readonly type: "attach";
|
|
@@ -45,6 +58,15 @@ export type TabToWorker =
|
|
|
45
58
|
| { readonly type: "revalidate_failed"; readonly runId: number; readonly cause: string }
|
|
46
59
|
| { readonly type: "frame_failed"; readonly generation: number; readonly cursor: Cursor | null };
|
|
47
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Every message the host sends a tab. Two of them are obligations rather than notifications: a
|
|
63
|
+
* `heartbeat` must be answered with `heartbeat_ack` or the host expires the port and stops folding this
|
|
64
|
+
* tab's readings, and a `revalidate_run` must be answered with `revalidate_done` or `revalidate_failed`
|
|
65
|
+
* carrying the same `runId`, or the host's run stays pending until this port expires, leaves, or the
|
|
66
|
+
* run's own signal aborts, which the runtime does at `revalidateTimeoutMs` and on `stop()`. A `state`
|
|
67
|
+
* message precedes the `lifecycle` record of the same transition, so a tab reading its own state inside
|
|
68
|
+
* that handler reads the new one.
|
|
69
|
+
*/
|
|
48
70
|
export type WorkerToTab =
|
|
49
71
|
| { readonly type: "frame"; readonly frame: Frame; readonly generation: number }
|
|
50
72
|
| { readonly type: "lifecycle"; readonly event: LifecycleEvent }
|
package/src/reducer.ts
CHANGED
|
@@ -33,6 +33,13 @@ export type ClientState =
|
|
|
33
33
|
| { kind: "offline"; generation: number; attempt: number; visible: boolean }
|
|
34
34
|
| { kind: "hidden_closed"; generation: number; attempt: number; online: boolean };
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Why a stream ended. `eof`, `error` and the two `reset:` reasons are read off the wire; the rest
|
|
38
|
+
* are the client's own decisions — silence past the watchdog interval, a frame over the buffer
|
|
39
|
+
* cap, and the three ways a hold ends without delivering. An attempt still connecting reports one
|
|
40
|
+
* of these too, and the reducer reads every one of them as a `network` connect failure there. All
|
|
41
|
+
* of them take one transition and none discards the cursor, so the next attempt resumes from it.
|
|
42
|
+
*/
|
|
36
43
|
export type StreamEnd =
|
|
37
44
|
| "eof"
|
|
38
45
|
| "error"
|
|
@@ -44,6 +51,14 @@ export type StreamEnd =
|
|
|
44
51
|
| "hold_overflow"
|
|
45
52
|
| "revalidate_failed";
|
|
46
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Why an attempt never became an open stream: either of the two connect-phase deadlines, a
|
|
56
|
+
* rejected fetch, a status other than 200, a media type other than `text/event-stream`, or one
|
|
57
|
+
* of the two hello refusals. Each is reported as a `connect_failed` record, and the attempt then
|
|
58
|
+
* backs off — or waits in `offline` instead, when the network reading has already gone false.
|
|
59
|
+
* `wire_unsupported` carries the revision the server announced and emits its own record beside
|
|
60
|
+
* the shared one.
|
|
61
|
+
*/
|
|
47
62
|
export type ConnectFailure =
|
|
48
63
|
| { kind: "timeout_headers" }
|
|
49
64
|
| { kind: "timeout_hello" }
|
|
@@ -58,6 +73,14 @@ interface FromTransport {
|
|
|
58
73
|
readonly generation: number;
|
|
59
74
|
}
|
|
60
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Every input the reducer accepts: the caller's three verbs, one connection attempt's reports, the
|
|
78
|
+
* four timer expiries, and the platform's visibility and network events. The caller supplies what
|
|
79
|
+
* the reducer refuses to read for itself — `now` from its clock, `rand` drawn in [0, 1) for the
|
|
80
|
+
* backoff, and `retryMs`, the reconnection delay in force, which floors the backoff after a stream
|
|
81
|
+
* that was open ends. A report tagged with a generation other than the current one is ignored,
|
|
82
|
+
* which is how a late answer from a replaced attempt is dropped rather than acted on.
|
|
83
|
+
*/
|
|
61
84
|
export type ClientEvent =
|
|
62
85
|
| { type: "start"; now: number; visible: boolean; online: boolean }
|
|
63
86
|
| { type: "stop" }
|
|
@@ -83,6 +106,12 @@ export type ClientEvent =
|
|
|
83
106
|
| { type: "pagehide"; now: number }
|
|
84
107
|
| { type: "pageshow"; now: number; rand: number; retryMs: number };
|
|
85
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The four timers the reducer arms and disarms by name, never by handle: byte silence, the
|
|
111
|
+
* hidden-tab close, the backoff delay, and the connected time after which the attempt counter
|
|
112
|
+
* resets. The runtime owns the handles and dispatches the matching expiry event; arming one that
|
|
113
|
+
* is already armed replaces it.
|
|
114
|
+
*/
|
|
86
115
|
export type Timer = "watchdog" | "hidden" | "backoff" | "stable";
|
|
87
116
|
|
|
88
117
|
/**
|
|
@@ -98,6 +127,11 @@ export type Effect =
|
|
|
98
127
|
| { kind: "revalidate"; cause: RevalidateCause }
|
|
99
128
|
| { kind: "emit"; event: LifecycleEvent };
|
|
100
129
|
|
|
130
|
+
/**
|
|
131
|
+
* One transition's result: the state to hold from now on, and the effects to execute in the order
|
|
132
|
+
* given. The reducer performs none of them, so a caller that drops `effects` has a state machine
|
|
133
|
+
* that transitions and a client that does nothing.
|
|
134
|
+
*/
|
|
101
135
|
export interface Reduction {
|
|
102
136
|
readonly state: ClientState;
|
|
103
137
|
readonly effects: Effect[];
|
package/src/stream.ts
CHANGED
|
@@ -20,6 +20,13 @@ import {
|
|
|
20
20
|
} from "./visibility.js";
|
|
21
21
|
import { type Cursor, type Hello, compareOffset } from "./wire.js";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* How one stream is built. Four fields are required — `url`, `versions`, `onFrame` and
|
|
25
|
+
* `revalidate` — and the rest have defaults that make a plain browser tab work unconfigured: the
|
|
26
|
+
* timing is filled from DEFAULT_TIMING, both wire bounds are this package's WIRE, and the
|
|
27
|
+
* visibility and network readings are the DOM sources, taken lazily at start(). A consumer replaces
|
|
28
|
+
* those two for a worker, a test, or a host folding several tabs into one connection.
|
|
29
|
+
*/
|
|
23
30
|
export interface StreamOptions {
|
|
24
31
|
readonly url: string;
|
|
25
32
|
/** Defaults to globalThis.fetch; the place to merge `credentials: "include"` or a bearer header. */
|
|
@@ -42,12 +49,26 @@ export interface StreamOptions {
|
|
|
42
49
|
readonly revalidate: (ctx: RevalidateContext) => Promise<void>;
|
|
43
50
|
}
|
|
44
51
|
|
|
52
|
+
/**
|
|
53
|
+
* One application frame, as `onFrame` receives it: the event name, the data payload exactly as it
|
|
54
|
+
* arrived, and the frame's position when it carried one. Library frames are never delivered here,
|
|
55
|
+
* `id` is null for a frame the server published without one, and a frame whose id is not a valid
|
|
56
|
+
* `epoch:offset` is reported as `bad_cursor` and dropped instead. Delivering a frame advances the
|
|
57
|
+
* cursor past it whether or not the handler threw: a throw earns a revalidation, never a redelivery.
|
|
58
|
+
*/
|
|
45
59
|
export interface Frame {
|
|
46
60
|
readonly type: string;
|
|
47
61
|
readonly data: string;
|
|
48
62
|
readonly id: Cursor | null;
|
|
49
63
|
}
|
|
50
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The context of one reconciliation run. Runs are single-flight, so a body is never entered while
|
|
67
|
+
* another is in flight, and at most one more run is ever queued behind the current one. Every fetch
|
|
68
|
+
* the body makes must take `signal`: the runtime aborts it at revalidateTimeoutMs and ends the
|
|
69
|
+
* connection rather than waiting, and a body that ignores the signal keeps running against state
|
|
70
|
+
* the runtime has already given up on.
|
|
71
|
+
*/
|
|
51
72
|
export interface RevalidateContext {
|
|
52
73
|
readonly cause: RevalidateCause;
|
|
53
74
|
/** The version map's epoch when the run started. */
|
|
@@ -59,6 +80,13 @@ export interface RevalidateContext {
|
|
|
59
80
|
readonly signal: AbortSignal;
|
|
60
81
|
}
|
|
61
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The runtime's handle. Frames and records do not arrive through it — they go to the `onFrame` and
|
|
85
|
+
* `onLifecycle` of the options the stream was created with — so what is left here is the four verbs
|
|
86
|
+
* and the two readings. `start()` is ignored unless the stream is stopped, and `cursor()` is the
|
|
87
|
+
* position the next connect will present as `Last-Event-ID`, or null when it will ask for a fresh
|
|
88
|
+
* hello instead.
|
|
89
|
+
*/
|
|
62
90
|
export interface Stream {
|
|
63
91
|
start(): void;
|
|
64
92
|
/** For leaving the page: aborts the in-flight revalidate's signal. */
|
package/src/tab.ts
CHANGED
|
@@ -35,6 +35,15 @@ export interface TabFallback {
|
|
|
35
35
|
readonly headers: Record<string, string>;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* How this tab attaches. `spawn` is called for every attempt at the worker — the first one, each respawn
|
|
40
|
+
* after a silent window, the re-spawn on `pageshow` when the page came back with no live port, and each
|
|
41
|
+
* recovery probe from fallback mode — so it must build a fresh worker each time rather than return a
|
|
42
|
+
* held one; a throw from it is read as the worker being unavailable. `fallback` is called when the
|
|
43
|
+
* ladder gives up, and immediately where `SharedWorker` is absent. `revalidate` is optional, and a tab
|
|
44
|
+
* without one reports every routed run as done, which is correct only when the host's own body did the
|
|
45
|
+
* reconciliation.
|
|
46
|
+
*/
|
|
38
47
|
export interface AttachOptions {
|
|
39
48
|
/** Constructs the worker with the fixed URL, name and options; called on every spawn. */
|
|
40
49
|
readonly spawn: () => SharedWorkerLike;
|
|
@@ -55,6 +64,13 @@ export interface AttachOptions {
|
|
|
55
64
|
readonly revalidateTimeoutMs?: number;
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
/**
|
|
68
|
+
* This tab's handle on the profile's stream. Every verb works the same in both modes, which is the point
|
|
69
|
+
* of it: `mode()` reports whether they currently reach the worker host over a port or this tab's own
|
|
70
|
+
* fallback stream, and a consumer needs it for reporting rather than for branching. `state()` is the
|
|
71
|
+
* host's last broadcast in worker mode, so it reads null until the first one arrives, and the fallback
|
|
72
|
+
* stream's own state otherwise.
|
|
73
|
+
*/
|
|
58
74
|
export interface TabAttachment {
|
|
59
75
|
/** Leaves the host; `logout` also ends the profile's stream, or this tab's fallback stream. */
|
|
60
76
|
detach(cause?: "unload" | "logout"): void;
|
package/src/timing.ts
CHANGED
|
@@ -58,6 +58,11 @@ export interface TimingConfig {
|
|
|
58
58
|
readonly maxBufferBytes: number;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The value every field a caller leaves unset takes. Each one is pinned, directly or by derivation,
|
|
63
|
+
* to the repository's `timing.json` — the single source both halves of the protocol read — so a
|
|
64
|
+
* client default and the server behaviour it assumes cannot drift apart.
|
|
65
|
+
*/
|
|
61
66
|
export const DEFAULT_TIMING: TimingConfig = {
|
|
62
67
|
connectTimeoutMs: 15_000,
|
|
63
68
|
helloTimeoutMs: 10_000,
|
|
@@ -77,7 +82,12 @@ export const DEFAULT_TIMING: TimingConfig = {
|
|
|
77
82
|
maxBufferBytes: MAX_FRAME_BYTES,
|
|
78
83
|
};
|
|
79
84
|
|
|
80
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Silence watchdog derivation follows Yaffle/EventSource: max(3 beats, 15 s), 45 s at the
|
|
87
|
+
* default keepalive. `keepaliveMs` is the interval the hello reported, so a server running a
|
|
88
|
+
* shorter one shortens the watchdog until the floor takes over; the value is re-derived on
|
|
89
|
+
* every re-arm rather than held.
|
|
90
|
+
*/
|
|
81
91
|
export function watchdogMs(keepaliveMs: number, cfg: TimingConfig = DEFAULT_TIMING): number {
|
|
82
92
|
return Math.max(cfg.watchdogBeats * keepaliveMs, cfg.watchdogFloorMs);
|
|
83
93
|
}
|
package/src/transport.ts
CHANGED
|
@@ -38,6 +38,13 @@ export interface TransportCallbacks {
|
|
|
38
38
|
readonly onEnd: (generation: number, reason: StreamEnd) => void;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* One attempt's inputs. `cursor` is presented as `Last-Event-ID` and `maxWire` as `SSE-Wire`, both
|
|
43
|
+
* merged under `headers`, so a caller's header of the same name wins over the transport's own — the
|
|
44
|
+
* seam a consumer uses to override them, and the one it must not trip over by accident. `generation`
|
|
45
|
+
* tags every callback with it, which is what lets the runtime tell a live report from one belonging
|
|
46
|
+
* to an attempt it has already replaced.
|
|
47
|
+
*/
|
|
41
48
|
export interface ConnectOptions {
|
|
42
49
|
readonly url: string;
|
|
43
50
|
readonly fetch: typeof fetch;
|
|
@@ -50,6 +57,11 @@ export interface ConnectOptions {
|
|
|
50
57
|
readonly callbacks: TransportCallbacks;
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A connection attempt in progress. `abort` is the whole surface: the attempt reports its outcome
|
|
62
|
+
* through the callbacks it was given, and aborting it silences those callbacks for good, the fetch
|
|
63
|
+
* and the reader it is still waiting on included. A second abort does nothing.
|
|
64
|
+
*/
|
|
53
65
|
export interface Connection {
|
|
54
66
|
/** Ends the attempt; nothing is dispatched afterwards. */
|
|
55
67
|
abort(reason: string): void;
|
package/src/versions.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface VersionMap {
|
|
|
29
29
|
snapshot(): { epoch: string | null; held: Held[] };
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The map's one hook. A stamp from a foreign epoch is not an error the application has to handle:
|
|
34
|
+
* observe() ignores it and answers false, and this is how the application learns it happened.
|
|
35
|
+
* staleListener reports the same events, which is the path the runtime uses for its `stale_stamp`
|
|
36
|
+
* records, so a consumer that wants both gets both.
|
|
37
|
+
*/
|
|
32
38
|
export interface VersionMapOptions {
|
|
33
39
|
/** Called for every stamp ignored because its epoch differs from the bound epoch. */
|
|
34
40
|
readonly onStale?: (subject: Subject, epoch: string) => void;
|
package/src/visibility.ts
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The six page events a source reports. Two of them never reach the reducer: adaptVisibility folds
|
|
3
|
+
* `freeze` onto `hidden` and `resume` onto `visible`, the latter only while the source still reads
|
|
4
|
+
* visible, because a resumed page may have been resumed in the background.
|
|
5
|
+
*/
|
|
1
6
|
export type VisibilityEvent = "visible" | "hidden" | "pagehide" | "pageshow" | "freeze" | "resume";
|
|
2
7
|
|
|
3
8
|
/** The four visibility inputs the reducer accepts; the adapter folds freeze and resume into them. */
|
|
4
9
|
export type VisibilityInput = "visible" | "hidden" | "pagehide" | "pageshow";
|
|
5
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The page-visibility reading, injected rather than assumed: `visible()` is asked on demand and
|
|
13
|
+
* `listen` returns the unsubscribe for the stream of events. createDOMVisibilitySource is the
|
|
14
|
+
* implementation over `document` and `window`; the worker host passes the fold over its tabs
|
|
15
|
+
* instead, and a test passes whatever it wants to drive.
|
|
16
|
+
*/
|
|
6
17
|
export interface VisibilitySource {
|
|
7
18
|
visible(): boolean;
|
|
8
19
|
listen(cb: (ev: VisibilityEvent) => void): () => void;
|
|
9
20
|
}
|
|
10
21
|
|
|
22
|
+
/**
|
|
23
|
+
* One visibility reading shared by every subscriber. The source is listened to only while at least
|
|
24
|
+
* one subscriber is attached, so a manager nobody uses registers no listener, and a pin set through
|
|
25
|
+
* setVisible replaces the source's answer for readers and emitters alike.
|
|
26
|
+
*/
|
|
11
27
|
export interface VisibilityManager {
|
|
12
28
|
isVisible(): boolean;
|
|
13
29
|
/** The source is listened to from the first subscriber to the last. */
|
package/src/wire.ts
CHANGED
|
@@ -27,10 +27,19 @@ export interface Hello {
|
|
|
27
27
|
readonly keepalive_event: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Why a hello was refused. Both reasons end the attempt in its connect phase, so both take the
|
|
32
|
+
* jitter backoff ladder — or wait in `offline` instead, when the network reading has already gone
|
|
33
|
+
* false — rather than the server's `retry:` delay, which a refused attempt never reaches.
|
|
34
|
+
*/
|
|
31
35
|
export type HelloRefusal =
|
|
32
36
|
{ kind: "bad_hello" } | { kind: "wire_unsupported"; wire: number | null };
|
|
33
37
|
|
|
38
|
+
/**
|
|
39
|
+
* validateHello's answer: the accepted hello, or the reason the frame was refused. A refusal
|
|
40
|
+
* ends the attempt before it counts as open and leaves the client's cursor untouched, so the
|
|
41
|
+
* next connect presents the same one.
|
|
42
|
+
*/
|
|
34
43
|
export type HelloResult = { ok: true; hello: Hello } | { ok: false; reason: HelloRefusal };
|
|
35
44
|
|
|
36
45
|
const EPOCH_RE = /^[0-9a-f]{16}$/;
|
package/src/worker.ts
CHANGED
|
@@ -34,6 +34,13 @@ export interface TabSet {
|
|
|
34
34
|
size(): number;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The host's options: a stream's, minus the three it supplies itself — the visibility and network
|
|
39
|
+
* readings, which it folds from its tabs, and `onFrame`, which it spends fanning frames to the ports.
|
|
40
|
+
* `revalidate` is widened to receive the attached TabSet beside the context, so the application, not
|
|
41
|
+
* this package, decides whether the reconciliation body runs in the worker or in the tabs. Everything
|
|
42
|
+
* else is a stream's, `versions` included: the profile has one map, and it lives here.
|
|
43
|
+
*/
|
|
37
44
|
export interface WorkerHostOptions extends Omit<
|
|
38
45
|
StreamOptions,
|
|
39
46
|
"visibility" | "online" | "onFrame" | "onLifecycle" | "revalidate"
|
|
@@ -46,6 +53,12 @@ export interface WorkerHostOptions extends Omit<
|
|
|
46
53
|
readonly heartbeatMs?: number;
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The profile's stream owner: one stream, one cursor and one version map behind however many tabs.
|
|
58
|
+
* The stream starts when the first tab attaches, and it is stopped by `close()`, by a `logout` detach
|
|
59
|
+
* or by a 401 — not by the last port going away, since the browser keeps a SharedWorker alive for its
|
|
60
|
+
* owner documents and a tab that navigates is expected back.
|
|
61
|
+
*/
|
|
49
62
|
export interface WorkerHost {
|
|
50
63
|
/** Called from the SharedWorker's onconnect with each new port. */
|
|
51
64
|
attach(port: MessagePort): void;
|