@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.
- package/LICENSE +201 -0
- package/NOTICE +28 -0
- package/README.md +37 -2
- package/dist/client.d.ts +138 -0
- package/dist/client.js +250 -0
- package/dist/feed/connection.d.ts +158 -0
- package/dist/feed/connection.js +427 -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 +246 -0
- package/dist/generated/kit.js +148 -0
- package/dist/generated/workorder-events.d.ts +47 -0
- package/dist/generated/workorder-events.js +10 -0
- package/dist/generated/workorder.d.ts +3522 -0
- package/dist/generated/workorder.js +74 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/routes.d.ts +10 -0
- package/dist/routes.js +32 -0
- package/package.json +32 -4
|
@@ -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;
|