@cplieger/sse 1.0.0 → 1.1.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 CHANGED
File without changes
package/NOTICE ADDED
@@ -0,0 +1,3 @@
1
+ sse
2
+ Copyright 2026 cplieger
3
+ https://github.com/cplieger/sse
package/jsr.json CHANGED
@@ -1,12 +1,23 @@
1
1
  {
2
2
  "name": "@cplieger/sse",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "license": "Apache-2.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
7
7
  },
8
8
  "publish": {
9
- "include": ["LICENSE", "README.md", "THIRD_PARTY_NOTICES.md", "src/**/*.ts", "jsr.json"],
10
- "exclude": ["src/**/*.test.ts", "src/**/*-setup.ts", "src/**/test-helpers/**"]
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.0.0",
3
+ "version": "1.1.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
- "@types/node": "24.13.4",
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": "24.13.5",
55
62
  "@typescript/native": "npm:typescript@7.0.2",
56
- "@vitest/browser": "5.0.0",
57
- "@vitest/browser-playwright": "5.0.0",
58
- "@vitest/coverage-v8": "5.0.0",
63
+ "@vitest/browser": "5.0.1",
64
+ "@vitest/browser-playwright": "5.0.1",
65
+ "@vitest/coverage-v8": "5.0.1",
59
66
  "eslint": "10.10.0",
60
- "eventsource-parser": "4.1.0",
61
- "fast-check": "4.9.0",
67
+ "eventsource-parser": "4.1.1",
68
+ "fast-check": "4.10.1",
69
+ "knip": "6.36.0",
62
70
  "playwright": "1.63.0",
63
- "prettier": "3.9.6",
71
+ "prettier": "3.9.7",
64
72
  "typescript": "npm:@typescript/typescript6@6.0.2",
65
73
  "typescript-eslint": "8.70.0",
66
- "vitest": "5.0.0"
74
+ "vitest": "5.0.1"
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. */
@@ -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
- // Silence watchdog derivation follows Yaffle/EventSource: max(3 beats, 15 s), 45 s at the default keepalive.
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
- /** Why a hello was refused; both reasons take the ordinary backoff ladder. */
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;