@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.
- package/LICENSE +201 -0
- package/NOTICE +28 -0
- package/README.md +56 -2
- package/dist/client.d.ts +223 -0
- package/dist/client.js +500 -0
- package/dist/feed/connection.d.ts +179 -0
- package/dist/feed/connection.js +457 -0
- package/dist/feed/sse.d.ts +51 -0
- package/dist/feed/sse.js +134 -0
- package/dist/generated/feed.d.ts +199 -0
- package/dist/generated/feed.js +16 -0
- package/dist/generated/kit.d.ts +374 -0
- package/dist/generated/kit.js +258 -0
- package/dist/generated/shares.d.ts +214 -0
- package/dist/generated/shares.js +17 -0
- package/dist/generated/workorder-events.d.ts +47 -0
- package/dist/generated/workorder-events.js +10 -0
- package/dist/generated/workorder.d.ts +3775 -0
- package/dist/generated/workorder.js +74 -0
- package/dist/generated/wsstream.d.ts +34 -0
- package/dist/generated/wsstream.js +28 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +24 -0
- package/dist/phone.d.ts +11 -0
- package/dist/phone.js +24 -0
- package/dist/routes.d.ts +32 -0
- package/dist/routes.js +69 -0
- package/dist/signin.d.ts +48 -0
- package/dist/signin.js +74 -0
- package/dist/stepup.d.ts +87 -0
- package/dist/stepup.js +66 -0
- package/dist/stream/connection.d.ts +145 -0
- package/dist/stream/connection.js +377 -0
- package/dist/terminal/view.d.ts +168 -0
- package/dist/terminal/view.js +546 -0
- package/dist/terminalwire/codec.d.ts +182 -0
- package/dist/terminalwire/codec.js +556 -0
- package/dist/terminalwire/collapse.d.ts +4 -0
- package/dist/terminalwire/collapse.js +7 -0
- package/dist/unreached.d.ts +26 -0
- package/dist/unreached.js +30 -0
- 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;
|
package/dist/feed/sse.js
ADDED
|
@@ -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
|
+
}
|