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

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