@kottera/tera-web 0.0.0-stage → 0.1.0-rc.2

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.
@@ -0,0 +1,158 @@
1
+ import type { FeedFrameRecord, FeedFrameStatus } from "../generated/feed.ts";
2
+ import { type FeedOpenError, type FeedOpener } from "./sse.ts";
3
+ export declare const BASE_DELAY_MS = 1000;
4
+ export declare const MAX_DELAY_MS = 30000;
5
+ /** Replays allowed in {@link REPLAY_WINDOW_MS} before a loss is surfaced. */
6
+ export declare const REPLAY_LIMIT = 3;
7
+ export declare const REPLAY_WINDOW_MS: number;
8
+ /** Added to twice the feed's heartbeat interval before a silent stream is given up on. */
9
+ export declare const HEARTBEAT_GRACE_MS = 5000;
10
+ /** The SSE event the kit closes a stream with when the person's token expires (D2 item 6). */
11
+ export declare const REAUTHENTICATE_EVENT = "reauthenticate";
12
+ /**
13
+ * One feed frame as the stream carried it, before its `type` is read. A frame
14
+ * of a known type is handed on as the type generated for it from
15
+ * `sdk/feed/schema/` ({@link FeedFrameRecord}, {@link FeedFrameStatus}); one
16
+ * of a type this package does not know is skipped (ADR-143 D13). The
17
+ * connection reads a frame's `type`, a status frame's `status` and drop
18
+ * counts, and a record's order key — and nothing else: the payload's contract
19
+ * is its publishing family's.
20
+ */
21
+ export type FeedFrame = {
22
+ readonly type: string;
23
+ readonly [field: string]: unknown;
24
+ };
25
+ /**
26
+ * Where the connection is.
27
+ *
28
+ * reconnecting a stream is being opened, or will be in `delayMs`; attempt 0
29
+ * is the first open and an immediate reopen
30
+ * replaying open, and the run's stored history is still arriving
31
+ * live open, history read: records now are live
32
+ * refused the route refused; a person has to act
33
+ * stopped nothing is wanted any more
34
+ */
35
+ export type FeedConnectionPhase = "reconnecting" | "replaying" | "live" | "refused" | "stopped";
36
+ /** A loss no replay is repairing, surfaced to the page. */
37
+ export interface FeedLoss {
38
+ /** The status value that reported it (`records_dropped`, `backfill_gap`, …). */
39
+ readonly status: string;
40
+ /**
41
+ * `replays-exhausted`: {@link REPLAY_LIMIT} replays in {@link REPLAY_WINDOW_MS}
42
+ * did not stop it. `unrepairable`: a replay cannot bring the records back.
43
+ */
44
+ readonly reason: "replays-exhausted" | "unrepairable";
45
+ }
46
+ export interface FeedConnectionState {
47
+ readonly phase: FeedConnectionPhase;
48
+ /** Consecutive failed attempts; 0 once a connection has gone live. */
49
+ readonly attempt: number;
50
+ /** How long until the scheduled reconnect, while `reconnecting`; 0 when it is under way. */
51
+ readonly delayMs: number;
52
+ /** Why the last stream ended, in the words it ended with. */
53
+ readonly lastError: string | null;
54
+ /** The refusal, while `refused`. */
55
+ readonly refusal: FeedOpenError | null;
56
+ /** A loss no replay is repairing, or null. */
57
+ readonly loss: FeedLoss | null;
58
+ }
59
+ export interface Clock {
60
+ now(): number;
61
+ setTimeout(fn: () => void, ms: number): unknown;
62
+ clearTimeout(handle: unknown): void;
63
+ /** A number in [0, 1), for the jitter. */
64
+ random(): number;
65
+ }
66
+ /** Something outside the stream saying now is a good time to reconnect. */
67
+ export type FeedSignal = "visible" | "online";
68
+ /** Subscribes to {@link FeedSignal}s; returns the unsubscribe. */
69
+ export type FeedSignals = (notify: (signal: FeedSignal) => void) => () => void;
70
+ /** The browser's signals: the document becoming visible, and the window coming online. */
71
+ export declare const browserSignals: FeedSignals;
72
+ /**
73
+ * defaultOrderKey reads the order ADR-083 D7's contract names: the id of a
74
+ * Work Order event (ADR-078 D3), monotonic across every Work Order and the
75
+ * cursor `work_order.events` replays from, carried as the record payload's
76
+ * `id`. A record without one has no key.
77
+ */
78
+ export declare function defaultOrderKey(record: FeedFrameRecord): number | undefined;
79
+ export interface FeedConnectionOptions {
80
+ /** The feed's name, as the plugin's manifest declares it. */
81
+ feed: string;
82
+ /** Narrows the feed to these of its topic filters (the feed's `topics` parameter). */
83
+ topics?: readonly string[];
84
+ /** Each record not already delivered, in order. */
85
+ onRecord: (record: FeedFrameRecord) => void;
86
+ /** Every status frame, as it arrives. Heartbeats are absorbed and never reach it (ADR-083 D7). */
87
+ onStatus?: (status: FeedFrameStatus) => void;
88
+ /** Every change of {@link FeedConnectionState}. */
89
+ onChange?: (state: FeedConnectionState) => void;
90
+ /**
91
+ * A record's place in the feed's order, for de-duplication across replays;
92
+ * {@link defaultOrderKey} when absent. A record with no key cannot be told
93
+ * apart from one already delivered, so it is delivered every time it
94
+ * arrives: a feed whose records carry no Work Order event id passes the key
95
+ * its own records are ordered by.
96
+ */
97
+ orderKey?: (record: FeedFrameRecord) => number | undefined;
98
+ /** Opens one stream; {@link fetchOpener} when absent. */
99
+ open?: FeedOpener;
100
+ clock?: Clock;
101
+ /** {@link browserSignals} when absent. */
102
+ signals?: FeedSignals;
103
+ }
104
+ export declare class FeedConnection {
105
+ private readonly options;
106
+ private readonly clock;
107
+ private readonly open;
108
+ private readonly orderKey;
109
+ private view;
110
+ private unsubscribe;
111
+ private generation;
112
+ private controller;
113
+ private retryTimer;
114
+ private silenceTimer;
115
+ private high;
116
+ private replays;
117
+ private opened;
118
+ private replaying;
119
+ private silenceMs;
120
+ private droppedSeen;
121
+ private lossPending;
122
+ private streamLoss;
123
+ constructor(options: FeedConnectionOptions);
124
+ get state(): FeedConnectionState;
125
+ /** The stream's URL: the feed route, narrowed to the topics, asking for the backfill. */
126
+ get url(): string;
127
+ /** Opens the first stream. Nothing delivered before is remembered. */
128
+ start(): void;
129
+ /** Ends everything; nothing this connection opened may deliver again. */
130
+ stop(): void;
131
+ /** A person asked: reopens from any ending, resetting the backoff and the replay ration. */
132
+ retry(): void;
133
+ private nudge;
134
+ private set;
135
+ private teardown;
136
+ private connect;
137
+ private event;
138
+ /**
139
+ * The person's token expired under an open stream (D7), told by a
140
+ * `reauthenticate` event or a `credential_expired` status: reconnecting
141
+ * carries a fresh assertion, so it is at once. A stream that never opened is
142
+ * not one the token ended, so it backs off rather than looping at once.
143
+ */
144
+ private expired;
145
+ private frame;
146
+ private status;
147
+ private deliver;
148
+ private droppedRose;
149
+ private unrepairable;
150
+ private lost;
151
+ private goLive;
152
+ /** One replay for a loss, within the ration; past it, the loss is surfaced. */
153
+ private replay;
154
+ /** Ends the current stream as a failure and schedules the next with backoff. */
155
+ private reopen;
156
+ private schedule;
157
+ private armSilence;
158
+ }
@@ -0,0 +1,427 @@
1
+ /**
2
+ * The feed connection: one ADR-083 feed held open for a page over D3's
3
+ * `GET /tera/v1/feeds/{feed}` stream, reopened after anything that ends it but
4
+ * a refusal (ADR-162 D4 item 2). It is wo-dashboard's `activity-connection.ts`
5
+ * moved here, read over the feed's own frames, which the kit relays verbatim
6
+ * (D2 item 6), rather than over that dashboard's relay protocol.
7
+ *
8
+ * # What ends a stream, and what happens next
9
+ *
10
+ * | Ending | Next |
11
+ * |-----------------------------------------------------|------------------------------------|
12
+ * | the open fails with no answer (network, sleep) | reconnect with backoff |
13
+ * | a 5xx, a 408 or a 429 | reconnect with backoff |
14
+ * | the stream ends, with or without a terminal status | reconnect with backoff |
15
+ * | nothing read past the heartbeat budget | reconnect with backoff |
16
+ * | a `reauthenticate` event (D7: the token expired) | reconnect at once |
17
+ * | a `credential_expired` status (D7, the same ending) | reconnect at once |
18
+ * | a status reporting loss | one replay, rationed (see Loss) |
19
+ * | any other 4xx, a 501, or a success that is not an | STOP: a refusal. A person has to |
20
+ * | event stream | act ({@link FeedConnection.retry}) |
21
+ *
22
+ * # Replay, and what is delivered
23
+ *
24
+ * Every connection asks for the run's stored history (`backfill=all`, the
25
+ * only start position the feed's splice has, ADR-112 D5), so every reconnect
26
+ * replays it. The connection is `replaying` from the feed's `open` status
27
+ * until `backfill_complete`, and `live` after. What it delivers is only the
28
+ * records not already delivered, in order: each record's order key — by
29
+ * default the Work Order event id ADR-083 D7's cursor orders by — must exceed
30
+ * the highest one delivered, so a replay adds what arrived while the
31
+ * connection was down and nothing it already handed over. A record carries
32
+ * nothing else the connection reads; its payload is the page's.
33
+ *
34
+ * # Loss
35
+ *
36
+ * A status saying records may have been lost — `records_dropped`,
37
+ * `upstream_lost`, a status this package does not know (ADR-143 D13), a
38
+ * batch that shed records, a running drop total that rose — is repaired by
39
+ * a replay, which reopens the stream at once. A replay re-reads the whole
40
+ * run, so it is rationed:
41
+ *
42
+ * - Not while history is still arriving. A replay that outruns the page drops
43
+ * at the same place every time, so the history is read to its end and the
44
+ * replay made once after.
45
+ * - Not for a loss a replay cannot repair: a `backfill_gap` (the store no
46
+ * longer reaches) or a drop for the feed's own `rate_limit`. Those are
47
+ * surfaced at once.
48
+ * - Not for a terminal status: the stream is ending, and the reconnect after
49
+ * it replays anyway.
50
+ * - At most {@link REPLAY_LIMIT} in {@link REPLAY_WINDOW_MS}. Past that the
51
+ * loss is surfaced on the state's `loss` rather than the run re-read for
52
+ * ever.
53
+ *
54
+ * # Backoff
55
+ *
56
+ * {@link BASE_DELAY_MS} doubling to {@link MAX_DELAY_MS}, each delay scaled by
57
+ * a jitter in [0.5, 1] so a daemon restart does not bring every tab back in
58
+ * the same millisecond. It resets when a connection goes live, not on a mere
59
+ * open, so a feed that accepts and then drops every connection still backs
60
+ * off. A scheduled reconnect is taken at once when the tab becomes visible or
61
+ * the network returns: the reason it failed has plausibly gone.
62
+ *
63
+ * It is a plain class with an injected clock, opener and signals, so its tests
64
+ * drive it with fake timers and a scripted stream and no network.
65
+ */
66
+ import { routePath } from "../routes.js";
67
+ import { fetchOpener, isRefusal } from "./sse.js";
68
+ export const BASE_DELAY_MS = 1_000;
69
+ export const MAX_DELAY_MS = 30_000;
70
+ /** Replays allowed in {@link REPLAY_WINDOW_MS} before a loss is surfaced. */
71
+ export const REPLAY_LIMIT = 3;
72
+ export const REPLAY_WINDOW_MS = 5 * 60_000;
73
+ /** Added to twice the feed's heartbeat interval before a silent stream is given up on. */
74
+ export const HEARTBEAT_GRACE_MS = 5_000;
75
+ /** The SSE event the kit closes a stream with when the person's token expires (D2 item 6). */
76
+ export const REAUTHENTICATE_EVENT = "reauthenticate";
77
+ /**
78
+ * The SSE events the kit relays a feed frame as: the event named after the
79
+ * frame's type (sdk/plugin/webui `EventRecord`, `EventStatus`). An unnamed
80
+ * event (`message`) is read as well, its frame's own `type` deciding. Any
81
+ * other event is skipped, as a frame type this package does not know is.
82
+ */
83
+ const FRAME_EVENTS = new Set(["message", "record", "status"]);
84
+ /**
85
+ * The feed status the daemon closes a person's feed with at the token's
86
+ * expiry (D7, `feed.StatusCredentialExpired`). It is the ending a
87
+ * `reauthenticate` event reports, reaching the connection as the feed's own
88
+ * frame, and it reconnects the same way: at once.
89
+ */
90
+ const CREDENTIAL_EXPIRED = "credential_expired";
91
+ const realClock = {
92
+ now: () => Date.now(),
93
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
94
+ clearTimeout: (handle) => clearTimeout(handle),
95
+ random: () => Math.random(),
96
+ };
97
+ /** The browser's signals: the document becoming visible, and the window coming online. */
98
+ export const browserSignals = (notify) => {
99
+ const doc = globalThis.document;
100
+ const win = globalThis;
101
+ const onVisibility = () => {
102
+ if (doc?.visibilityState === "visible")
103
+ notify("visible");
104
+ };
105
+ const onOnline = () => notify("online");
106
+ doc?.addEventListener("visibilitychange", onVisibility);
107
+ win.addEventListener?.("online", onOnline);
108
+ return () => {
109
+ doc?.removeEventListener("visibilitychange", onVisibility);
110
+ win.removeEventListener?.("online", onOnline);
111
+ };
112
+ };
113
+ /**
114
+ * defaultOrderKey reads the order ADR-083 D7's contract names: the id of a
115
+ * Work Order event (ADR-078 D3), monotonic across every Work Order and the
116
+ * cursor `work_order.events` replays from, carried as the record payload's
117
+ * `id`. A record without one has no key.
118
+ */
119
+ export function defaultOrderKey(record) {
120
+ const payload = record.payload;
121
+ if (typeof payload !== "object" || payload === null)
122
+ return undefined;
123
+ const id = payload.id;
124
+ return typeof id === "number" && Number.isSafeInteger(id) ? id : undefined;
125
+ }
126
+ /** The statuses that report no loss. Every other one this package does not handle by name is read as possible loss (ADR-143 D13). */
127
+ const INFORMATIONAL = new Set(["partial_authorization", "narrowed", "backfill_degraded"]);
128
+ const initialState = { phase: "stopped", attempt: 0, delayMs: 0, lastError: null, refusal: null, loss: null };
129
+ export class FeedConnection {
130
+ options;
131
+ clock;
132
+ open;
133
+ orderKey;
134
+ view = initialState;
135
+ unsubscribe = null;
136
+ // Every stream and timer this connection starts belongs to one generation;
137
+ // a callback from an older one writes nothing.
138
+ generation = 0;
139
+ controller = null;
140
+ retryTimer = null;
141
+ silenceTimer = null;
142
+ // Across streams: the highest order key delivered, and when replays were made.
143
+ high = -Infinity;
144
+ replays = [];
145
+ // Per stream.
146
+ opened = false;
147
+ replaying = false;
148
+ silenceMs = 0;
149
+ droppedSeen = 0;
150
+ lossPending = null;
151
+ streamLoss = null;
152
+ constructor(options) {
153
+ this.options = options;
154
+ this.clock = options.clock ?? realClock;
155
+ this.open = options.open ?? fetchOpener();
156
+ this.orderKey = options.orderKey ?? defaultOrderKey;
157
+ }
158
+ get state() {
159
+ return this.view;
160
+ }
161
+ /** The stream's URL: the feed route, narrowed to the topics, asking for the backfill. */
162
+ get url() {
163
+ const query = new URLSearchParams();
164
+ for (const topic of this.options.topics ?? [])
165
+ query.append("topics", topic);
166
+ query.set("backfill", "all");
167
+ return routePath("feeds", this.options.feed) + "?" + query.toString();
168
+ }
169
+ /** Opens the first stream. Nothing delivered before is remembered. */
170
+ start() {
171
+ this.stop();
172
+ this.high = -Infinity;
173
+ this.replays = [];
174
+ this.view = initialState;
175
+ this.unsubscribe = (this.options.signals ?? browserSignals)((signal) => this.nudge(signal));
176
+ this.connect();
177
+ }
178
+ /** Ends everything; nothing this connection opened may deliver again. */
179
+ stop() {
180
+ this.generation += 1;
181
+ this.teardown();
182
+ this.unsubscribe?.();
183
+ this.unsubscribe = null;
184
+ if (this.view.phase !== "stopped")
185
+ this.set({ phase: "stopped", delayMs: 0 });
186
+ }
187
+ /** A person asked: reopens from any ending, resetting the backoff and the replay ration. */
188
+ retry() {
189
+ this.replays = [];
190
+ this.view = { ...this.view, attempt: 0, loss: null };
191
+ this.connect();
192
+ }
193
+ nudge(_signal) {
194
+ if (this.retryTimer !== null)
195
+ this.connect();
196
+ }
197
+ set(patch) {
198
+ this.view = { ...this.view, ...patch };
199
+ this.options.onChange?.(this.view);
200
+ }
201
+ teardown() {
202
+ this.controller?.abort();
203
+ this.controller = null;
204
+ if (this.retryTimer !== null)
205
+ this.clock.clearTimeout(this.retryTimer);
206
+ if (this.silenceTimer !== null)
207
+ this.clock.clearTimeout(this.silenceTimer);
208
+ this.retryTimer = null;
209
+ this.silenceTimer = null;
210
+ }
211
+ connect() {
212
+ this.teardown();
213
+ const mine = ++this.generation;
214
+ const controller = new AbortController();
215
+ this.controller = controller;
216
+ this.opened = false;
217
+ this.replaying = false;
218
+ this.silenceMs = 0;
219
+ this.droppedSeen = 0;
220
+ this.lossPending = null;
221
+ this.streamLoss = null;
222
+ this.set({ phase: "reconnecting", delayMs: 0, refusal: null });
223
+ this.open(this.url, (e) => {
224
+ if (mine === this.generation)
225
+ this.event(e);
226
+ }, controller.signal).then(() => {
227
+ if (mine === this.generation)
228
+ this.reopen("the stream ended");
229
+ }, (err) => {
230
+ if (mine !== this.generation)
231
+ return;
232
+ if (isRefusal(err)) {
233
+ this.generation += 1;
234
+ this.teardown();
235
+ this.set({ phase: "refused", refusal: err, lastError: err.message, delayMs: 0 });
236
+ return;
237
+ }
238
+ this.reopen(err instanceof Error ? err.message : String(err));
239
+ });
240
+ }
241
+ event(e) {
242
+ this.armSilence();
243
+ if (e.event === REAUTHENTICATE_EVENT) {
244
+ this.expired("reauthenticate");
245
+ return;
246
+ }
247
+ if (!FRAME_EVENTS.has(e.event))
248
+ return;
249
+ let frame;
250
+ try {
251
+ frame = JSON.parse(e.data);
252
+ }
253
+ catch {
254
+ return;
255
+ }
256
+ if (typeof frame !== "object" || frame === null || typeof frame.type !== "string")
257
+ return;
258
+ // A named event carries the frame of its name; one that disagrees is not trusted.
259
+ if (e.event !== "message" && frame.type !== e.event)
260
+ return;
261
+ this.frame(frame);
262
+ }
263
+ /**
264
+ * The person's token expired under an open stream (D7), told by a
265
+ * `reauthenticate` event or a `credential_expired` status: reconnecting
266
+ * carries a fresh assertion, so it is at once. A stream that never opened is
267
+ * not one the token ended, so it backs off rather than looping at once.
268
+ */
269
+ expired(how) {
270
+ if (this.opened)
271
+ this.schedule(0, this.view.attempt, "the person's token expired");
272
+ else
273
+ this.reopen(`${how} before the stream opened`);
274
+ }
275
+ frame(f) {
276
+ switch (f.type) {
277
+ case "record":
278
+ this.deliver(f);
279
+ return;
280
+ case "batch": {
281
+ const records = Array.isArray(f.records) ? f.records : [];
282
+ for (const r of records) {
283
+ if (typeof r === "object" && r !== null && r.type === "record")
284
+ this.deliver(r);
285
+ }
286
+ const rose = this.droppedRose(f.dropped_total);
287
+ if (count(f.dropped) === 0 && !rose)
288
+ return;
289
+ if (f.drop_reason === "rate_limit")
290
+ this.unrepairable("records_dropped");
291
+ else
292
+ this.lost("records_dropped");
293
+ return;
294
+ }
295
+ case "heartbeat":
296
+ if (this.droppedRose(f.dropped_total))
297
+ this.lost("records_dropped");
298
+ return;
299
+ case "status":
300
+ this.options.onStatus?.(f);
301
+ this.status(f);
302
+ return;
303
+ }
304
+ // A frame type this package does not know is skipped (ADR-143 D13): loss
305
+ // is only ever reported on a status frame.
306
+ }
307
+ status(f) {
308
+ const status = typeof f.status === "string" ? f.status : "";
309
+ if (status === CREDENTIAL_EXPIRED) {
310
+ this.expired(status);
311
+ return;
312
+ }
313
+ if (count(f.close_code) > 0 || status === "shutting_down") {
314
+ // Terminal: the stream ends next, and the reconnect after it replays.
315
+ return;
316
+ }
317
+ switch (status) {
318
+ case "open":
319
+ this.opened = true;
320
+ this.droppedSeen = count(f.dropped_total);
321
+ this.silenceMs = count(f.heartbeat_interval_seconds) > 0 ? 2 * count(f.heartbeat_interval_seconds) * 1000 + HEARTBEAT_GRACE_MS : 0;
322
+ this.armSilence();
323
+ this.replaying = true;
324
+ this.set({ phase: "replaying" });
325
+ return;
326
+ case "backfill_complete":
327
+ case "backfill_unavailable":
328
+ this.goLive();
329
+ return;
330
+ case "backfill_gap":
331
+ this.unrepairable(status);
332
+ return;
333
+ case "records_dropped":
334
+ this.droppedRose(f.dropped_total);
335
+ if (f.drop_reason === "rate_limit")
336
+ this.unrepairable(status);
337
+ else
338
+ this.lost(status);
339
+ return;
340
+ }
341
+ if (!INFORMATIONAL.has(status))
342
+ this.lost(status);
343
+ }
344
+ deliver(record) {
345
+ const key = this.orderKey(record);
346
+ if (key !== undefined) {
347
+ if (key <= this.high)
348
+ return;
349
+ this.high = key;
350
+ }
351
+ this.options.onRecord(record);
352
+ }
353
+ droppedRose(total) {
354
+ const n = count(total);
355
+ if (n <= this.droppedSeen)
356
+ return false;
357
+ this.droppedSeen = n;
358
+ return true;
359
+ }
360
+ unrepairable(status) {
361
+ this.streamLoss = { status, reason: "unrepairable" };
362
+ this.set({ loss: this.streamLoss });
363
+ }
364
+ lost(status) {
365
+ if (this.replaying) {
366
+ this.lossPending ??= status;
367
+ return;
368
+ }
369
+ this.replay(status);
370
+ }
371
+ goLive() {
372
+ if (!this.replaying)
373
+ return;
374
+ this.replaying = false;
375
+ // A replay that went live without a loss of its own repaired the last one.
376
+ this.set({ phase: "live", attempt: 0, delayMs: 0, lastError: null, loss: this.streamLoss });
377
+ const pending = this.lossPending;
378
+ this.lossPending = null;
379
+ if (pending !== null)
380
+ this.replay(pending);
381
+ }
382
+ /** One replay for a loss, within the ration; past it, the loss is surfaced. */
383
+ replay(status) {
384
+ const now = this.clock.now();
385
+ this.replays = this.replays.filter((at) => now - at < REPLAY_WINDOW_MS);
386
+ if (this.replays.length >= REPLAY_LIMIT) {
387
+ this.set({ loss: { status, reason: "replays-exhausted" } });
388
+ return;
389
+ }
390
+ this.replays.push(now);
391
+ this.schedule(0, this.view.attempt, `records were reported lost (${status})`);
392
+ }
393
+ /** Ends the current stream as a failure and schedules the next with backoff. */
394
+ reopen(why) {
395
+ const attempt = this.view.attempt + 1;
396
+ const exp = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** (attempt - 1));
397
+ this.schedule(Math.round(exp * (0.5 + this.clock.random() * 0.5)), attempt, why);
398
+ }
399
+ schedule(delayMs, attempt, why) {
400
+ this.generation += 1;
401
+ this.teardown();
402
+ this.set({ phase: "reconnecting", attempt, delayMs, lastError: why });
403
+ if (delayMs === 0) {
404
+ this.connect();
405
+ return;
406
+ }
407
+ this.retryTimer = this.clock.setTimeout(() => {
408
+ this.retryTimer = null;
409
+ this.connect();
410
+ }, delayMs);
411
+ }
412
+ armSilence() {
413
+ if (this.silenceTimer !== null)
414
+ this.clock.clearTimeout(this.silenceTimer);
415
+ this.silenceTimer = null;
416
+ if (this.silenceMs <= 0)
417
+ return;
418
+ const mine = this.generation;
419
+ this.silenceTimer = this.clock.setTimeout(() => {
420
+ if (mine === this.generation)
421
+ this.reopen(`the stream fell silent past its heartbeat budget (${this.silenceMs}ms)`);
422
+ }, this.silenceMs);
423
+ }
424
+ }
425
+ function count(v) {
426
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
427
+ }
@@ -0,0 +1,51 @@
1
+ /** One dispatched server-sent event. `event` is `message` when none was named. */
2
+ export interface SSEEvent {
3
+ readonly event: string;
4
+ readonly data: string;
5
+ }
6
+ /**
7
+ * SSEParser turns the text of an event stream, in whatever chunks it arrives,
8
+ * into events, as the WHATWG event-stream grammar reads it: `\n`, `\r\n` and
9
+ * `\r` all end a line, a blank line dispatches, a line starting `:` is a
10
+ * comment, and `data` lines join with `\n`. `id` and `retry` are read and
11
+ * dropped — the connection keeps its own cursor and its own backoff.
12
+ */
13
+ export declare class SSEParser {
14
+ private buffer;
15
+ private event;
16
+ private data;
17
+ push(chunk: string): SSEEvent[];
18
+ private line;
19
+ }
20
+ /**
21
+ * Opens one stream: resolves when the stream ends, rejects when it could not
22
+ * be opened or failed while being read. Every event read is handed to
23
+ * `onEvent`, in order. Aborting `signal` ends it.
24
+ */
25
+ export type FeedOpener = (url: string, onEvent: (e: SSEEvent) => void, signal: AbortSignal) => Promise<void>;
26
+ /** A feed route that answered with something other than an event stream. */
27
+ export declare class FeedOpenError extends Error {
28
+ /** The HTTP status the route answered with. */
29
+ readonly status: number;
30
+ /** The answer's content type. */
31
+ readonly contentType: string;
32
+ /** As much of the body as was read: the kit's error envelope, on a refusal (ADR-162 D2 item 8). */
33
+ readonly body: string;
34
+ constructor(status: number, contentType: string, body: string);
35
+ }
36
+ /**
37
+ * isRefusal reports whether an open failed in a way trying again cannot fix,
38
+ * so the connection stops rather than retrying it (ADR-162 D4 item 2). A 4xx
39
+ * is a refusal — the person may not, or the feed is not there — except 408 and
40
+ * 429, which say "later". So is 501, and so is a success that is not an event
41
+ * stream: that is a page served where the kit's route should be, which no
42
+ * retry turns into a feed. Anything else — a 5xx, a dropped connection — is a
43
+ * transport failure, and is retried.
44
+ */
45
+ export declare function isRefusal(err: unknown): err is FeedOpenError;
46
+ /**
47
+ * fetchOpener is the {@link FeedOpener} a browser uses: a same-origin `GET`
48
+ * read as an event stream. `fetchFn` is injectable so the transport itself is
49
+ * tested without a network.
50
+ */
51
+ export declare function fetchOpener(fetchFn?: typeof fetch): FeedOpener;