@cplieger/sse 1.0.0
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 +202 -0
- package/README.md +77 -0
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/jsr.json +12 -0
- package/package.json +71 -0
- package/src/digest.ts +248 -0
- package/src/fold.ts +225 -0
- package/src/index.ts +102 -0
- package/src/lifecycle.ts +48 -0
- package/src/online.ts +98 -0
- package/src/parser.ts +193 -0
- package/src/port-protocol.ts +100 -0
- package/src/reducer.ts +548 -0
- package/src/stream.ts +737 -0
- package/src/tab.ts +458 -0
- package/src/timing.ts +98 -0
- package/src/transport.ts +278 -0
- package/src/versions.ts +134 -0
- package/src/visibility.ts +137 -0
- package/src/wire.ts +156 -0
- package/src/worker.ts +480 -0
package/src/stream.ts
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import type { HeldDiscardCause, LifecycleEvent, RevalidateCause } from "./lifecycle.js";
|
|
2
|
+
import { type OnlineManager, createOnlineManager } from "./online.js";
|
|
3
|
+
import {
|
|
4
|
+
type ClientEvent,
|
|
5
|
+
type ClientState,
|
|
6
|
+
type Effect,
|
|
7
|
+
type StreamEnd,
|
|
8
|
+
type Timer,
|
|
9
|
+
initialState,
|
|
10
|
+
reduce,
|
|
11
|
+
} from "./reducer.js";
|
|
12
|
+
import { DEFAULT_RETRY_MS, type TimingConfig, WIRE, resolveTiming } from "./timing.js";
|
|
13
|
+
import { type Connection, type TransportFrame, connect } from "./transport.js";
|
|
14
|
+
import { type VersionMap, bindListener, staleListener } from "./versions.js";
|
|
15
|
+
import {
|
|
16
|
+
type VisibilityInput,
|
|
17
|
+
type VisibilityManager,
|
|
18
|
+
adaptVisibility,
|
|
19
|
+
createVisibilityManager,
|
|
20
|
+
} from "./visibility.js";
|
|
21
|
+
import { type Cursor, type Hello, compareOffset } from "./wire.js";
|
|
22
|
+
|
|
23
|
+
export interface StreamOptions {
|
|
24
|
+
readonly url: string;
|
|
25
|
+
/** Defaults to globalThis.fetch; the place to merge `credentials: "include"` or a bearer header. */
|
|
26
|
+
readonly fetch?: typeof fetch;
|
|
27
|
+
/** Read at every request, so a host may fill `SSE-Client` after construction. */
|
|
28
|
+
readonly headers?: Record<string, string>;
|
|
29
|
+
readonly timing?: Partial<TimingConfig>;
|
|
30
|
+
readonly minWire?: number;
|
|
31
|
+
readonly maxWire?: number;
|
|
32
|
+
readonly visibility?: VisibilityManager;
|
|
33
|
+
readonly online?: OnlineManager;
|
|
34
|
+
/** Keepalive acknowledgement: POST `url` after every `everyBeats`-th keepalive (default 1). */
|
|
35
|
+
readonly alive?: { readonly url: string; readonly everyBeats?: number };
|
|
36
|
+
/** Bound to hello.epoch on every hello; the application fills it on commit. */
|
|
37
|
+
readonly versions: VersionMap;
|
|
38
|
+
/** Application frames only; a throw advances the cursor and schedules revalidate("hello"). */
|
|
39
|
+
readonly onFrame: (frame: Frame) => void;
|
|
40
|
+
readonly onLifecycle?: (ev: LifecycleEvent) => void;
|
|
41
|
+
/** The application's reconciliation, run single-flight; it must pass ctx.signal to every fetch. */
|
|
42
|
+
readonly revalidate: (ctx: RevalidateContext) => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Frame {
|
|
46
|
+
readonly type: string;
|
|
47
|
+
readonly data: string;
|
|
48
|
+
readonly id: Cursor | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RevalidateContext {
|
|
52
|
+
readonly cause: RevalidateCause;
|
|
53
|
+
/** The version map's epoch when the run started. */
|
|
54
|
+
readonly epoch: string | null;
|
|
55
|
+
readonly generation: number;
|
|
56
|
+
/** The map was just cleared by bind(); the application must run its full body without a digest. */
|
|
57
|
+
readonly full: boolean;
|
|
58
|
+
/** Aborted by stop() and at revalidateTimeoutMs, never by reconnect(). */
|
|
59
|
+
readonly signal: AbortSignal;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Stream {
|
|
63
|
+
start(): void;
|
|
64
|
+
/** For leaving the page: aborts the in-flight revalidate's signal. */
|
|
65
|
+
stop(): void;
|
|
66
|
+
/** Aborts the attempt and connects again in place; ignored in hidden_closed, offline and stopped. */
|
|
67
|
+
reconnect(): void;
|
|
68
|
+
state(): ClientState;
|
|
69
|
+
cursor(): Cursor | null;
|
|
70
|
+
/** Drops the cursor; the next connect presents none and earns a fresh hello. */
|
|
71
|
+
resetCursor(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Host-only controls over a stream; reached through streamInternals, not the public interface. */
|
|
75
|
+
export interface StreamInternals {
|
|
76
|
+
/** Schedules a revalidate under the single-flight rule, as an effect would. */
|
|
77
|
+
revalidate(cause: RevalidateCause, full: boolean): void;
|
|
78
|
+
/** Sets the queued slot's sticky full without starting a run. */
|
|
79
|
+
queueFull(): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type HeldEntry =
|
|
83
|
+
| {
|
|
84
|
+
readonly kind: "frame";
|
|
85
|
+
readonly generation: number;
|
|
86
|
+
readonly frame: Frame;
|
|
87
|
+
readonly bytes: number;
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
readonly kind: "adopt";
|
|
91
|
+
readonly generation: number;
|
|
92
|
+
readonly epoch: string;
|
|
93
|
+
readonly head: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
interface Slot {
|
|
97
|
+
readonly cause: RevalidateCause;
|
|
98
|
+
readonly full: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface Run {
|
|
102
|
+
readonly controller: AbortController;
|
|
103
|
+
readonly cause: RevalidateCause;
|
|
104
|
+
readonly startedAt: number;
|
|
105
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type RuntimeEnd = Extract<StreamEnd, HeldDiscardCause>;
|
|
109
|
+
|
|
110
|
+
const ALIVE_TIMEOUT_MS = 5_000;
|
|
111
|
+
|
|
112
|
+
const internals = new WeakMap<Stream, StreamInternals>();
|
|
113
|
+
|
|
114
|
+
function transportGeneration(event: ClientEvent): number | null {
|
|
115
|
+
switch (event.type) {
|
|
116
|
+
case "connected":
|
|
117
|
+
case "connect_failed":
|
|
118
|
+
case "byte":
|
|
119
|
+
case "stream_ended":
|
|
120
|
+
return event.generation;
|
|
121
|
+
default:
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function describe(error: unknown): string {
|
|
127
|
+
if (error instanceof Error) {
|
|
128
|
+
return error.message;
|
|
129
|
+
}
|
|
130
|
+
return String(error);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Creates the client runtime. Throws RangeError when `timing.maxBufferBytes` is below MAX_FRAME_BYTES. */
|
|
134
|
+
export function createStream(opts: StreamOptions): Stream {
|
|
135
|
+
const cfg = resolveTiming(opts.timing);
|
|
136
|
+
const doFetch: typeof fetch = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
137
|
+
const minWire = opts.minWire ?? WIRE;
|
|
138
|
+
const maxWire = opts.maxWire ?? WIRE;
|
|
139
|
+
const everyBeats = Math.max(1, opts.alive?.everyBeats ?? cfg.aliveEveryBeats);
|
|
140
|
+
|
|
141
|
+
let state: ClientState = initialState();
|
|
142
|
+
let cursor: Cursor | null = null;
|
|
143
|
+
const timers = new Map<Timer, ReturnType<typeof setTimeout>>();
|
|
144
|
+
|
|
145
|
+
let connection: { readonly generation: number; readonly conn: Connection } | null = null;
|
|
146
|
+
let hello: Hello | null = null;
|
|
147
|
+
let adopted = false;
|
|
148
|
+
let retryMs = DEFAULT_RETRY_MS;
|
|
149
|
+
let beats = 0;
|
|
150
|
+
let aliveScope: AbortController | null = null;
|
|
151
|
+
|
|
152
|
+
let held: HeldEntry[] = [];
|
|
153
|
+
let heldFrames = 0;
|
|
154
|
+
let heldBytes = 0;
|
|
155
|
+
|
|
156
|
+
let inflight: Run | null = null;
|
|
157
|
+
let queued: Slot | null = null;
|
|
158
|
+
let unverified = false;
|
|
159
|
+
let binding = false;
|
|
160
|
+
let knownEpoch = opts.versions.epoch();
|
|
161
|
+
const helloRun = { full: false, revalidated: false };
|
|
162
|
+
const reducerRevalidated = (): boolean => helloRun.revalidated;
|
|
163
|
+
let wasHiddenClosed = false;
|
|
164
|
+
|
|
165
|
+
let defaultVisibility: VisibilityManager | null = null;
|
|
166
|
+
let defaultOnline: OnlineManager | null = null;
|
|
167
|
+
let unsubscribes: (() => void)[] = [];
|
|
168
|
+
|
|
169
|
+
const now = (): number => Date.now();
|
|
170
|
+
const rand = (): number => Math.random();
|
|
171
|
+
|
|
172
|
+
function emit(event: LifecycleEvent): void {
|
|
173
|
+
opts.onLifecycle?.(event);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
bindListener(opts.versions, (epoch, _dropped) => {
|
|
177
|
+
const previous = knownEpoch;
|
|
178
|
+
knownEpoch = epoch;
|
|
179
|
+
if (binding || inflight === null || !queued?.full) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (epoch === previous) {
|
|
183
|
+
queued = { cause: queued.cause, full: false };
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
staleListener(opts.versions, (subject, epoch) => {
|
|
187
|
+
emit({ kind: "stale_stamp", subject, epoch });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
function arm(timer: Timer, ms: number): void {
|
|
191
|
+
disarm(timer);
|
|
192
|
+
timers.set(
|
|
193
|
+
timer,
|
|
194
|
+
setTimeout(() => {
|
|
195
|
+
timers.delete(timer);
|
|
196
|
+
fire(timer);
|
|
197
|
+
}, ms),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function disarm(timer: Timer): void {
|
|
202
|
+
const handle = timers.get(timer);
|
|
203
|
+
if (handle !== undefined) {
|
|
204
|
+
clearTimeout(handle);
|
|
205
|
+
timers.delete(timer);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function fire(timer: Timer): void {
|
|
210
|
+
switch (timer) {
|
|
211
|
+
case "watchdog":
|
|
212
|
+
dispatch({ type: "watchdog_fired", now: now(), rand: rand(), retryMs });
|
|
213
|
+
return;
|
|
214
|
+
case "hidden":
|
|
215
|
+
dispatch({ type: "hidden_timer_fired", now: now() });
|
|
216
|
+
if (state.kind === "hidden_closed") {
|
|
217
|
+
wasHiddenClosed = true;
|
|
218
|
+
emit({ kind: "hidden_closed", arm: "timer" });
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
case "backoff":
|
|
222
|
+
dispatch({ type: "backoff_elapsed", now: now() });
|
|
223
|
+
return;
|
|
224
|
+
case "stable":
|
|
225
|
+
dispatch({ type: "stable_elapsed", now: now() });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function execute(effect: Effect): void {
|
|
231
|
+
switch (effect.kind) {
|
|
232
|
+
case "connect":
|
|
233
|
+
startConnection(effect.generation);
|
|
234
|
+
return;
|
|
235
|
+
case "abort":
|
|
236
|
+
abortConnection(effect.generation, effect.reason);
|
|
237
|
+
return;
|
|
238
|
+
case "arm":
|
|
239
|
+
arm(effect.timer, effect.ms);
|
|
240
|
+
return;
|
|
241
|
+
case "disarm":
|
|
242
|
+
disarm(effect.timer);
|
|
243
|
+
return;
|
|
244
|
+
case "revalidate":
|
|
245
|
+
if (effect.cause === "hello") {
|
|
246
|
+
helloRun.revalidated = true;
|
|
247
|
+
schedule("hello", helloRun.full);
|
|
248
|
+
} else {
|
|
249
|
+
schedule(effect.cause, false);
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
case "emit":
|
|
253
|
+
emit(effect.event);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function dispatch(event: ClientEvent): void {
|
|
259
|
+
const eventGeneration = transportGeneration(event);
|
|
260
|
+
if (eventGeneration !== null && eventGeneration !== state.generation) {
|
|
261
|
+
emit({ kind: "stale_event", type: event.type, generation: eventGeneration });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const before = state;
|
|
265
|
+
const reduction = reduce(state, event, cfg);
|
|
266
|
+
state = reduction.state;
|
|
267
|
+
for (const effect of reduction.effects) {
|
|
268
|
+
execute(effect);
|
|
269
|
+
}
|
|
270
|
+
if (before.kind !== state.kind) {
|
|
271
|
+
emit({ kind: "state", from: before.kind, to: state.kind, generation: state.generation });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function startConnection(generation: number): void {
|
|
276
|
+
hello = null;
|
|
277
|
+
adopted = false;
|
|
278
|
+
retryMs = DEFAULT_RETRY_MS;
|
|
279
|
+
beats = 0;
|
|
280
|
+
aliveScope?.abort();
|
|
281
|
+
aliveScope = new AbortController();
|
|
282
|
+
const conn = connect({
|
|
283
|
+
url: opts.url,
|
|
284
|
+
fetch: doFetch,
|
|
285
|
+
headers: { ...opts.headers },
|
|
286
|
+
cursor,
|
|
287
|
+
minWire,
|
|
288
|
+
maxWire,
|
|
289
|
+
timing: cfg,
|
|
290
|
+
generation,
|
|
291
|
+
callbacks: {
|
|
292
|
+
onByte(g) {
|
|
293
|
+
dispatch({ type: "byte", generation: g, now: now() });
|
|
294
|
+
},
|
|
295
|
+
onConnected: handleConnected,
|
|
296
|
+
onConnectFailed(g, reason) {
|
|
297
|
+
if (g === state.generation) {
|
|
298
|
+
emit({ kind: "connect_failed", reason });
|
|
299
|
+
}
|
|
300
|
+
dispatch({ type: "connect_failed", generation: g, now: now(), reason, rand: rand() });
|
|
301
|
+
},
|
|
302
|
+
onFrame: handleFrame,
|
|
303
|
+
onKeepalive: handleKeepalive,
|
|
304
|
+
onUnknownFrame(g, type) {
|
|
305
|
+
if (g === state.generation) {
|
|
306
|
+
emit({ kind: "unknown_frame", type });
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
onBadCursor(g, id) {
|
|
310
|
+
if (g === state.generation) {
|
|
311
|
+
emit({ kind: "bad_cursor", id });
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
onRetry(g, ms) {
|
|
315
|
+
if (g === state.generation) {
|
|
316
|
+
retryMs = ms;
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
onEnd: handleEnd,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
connection = { generation, conn };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function dropConnection(generation: number, reason: string): void {
|
|
326
|
+
if (connection !== null && connection.generation === generation) {
|
|
327
|
+
connection.conn.abort(reason);
|
|
328
|
+
connection = null;
|
|
329
|
+
}
|
|
330
|
+
aliveScope?.abort();
|
|
331
|
+
aliveScope = null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function abortConnection(generation: number, reason: string): void {
|
|
335
|
+
dropConnection(generation, reason);
|
|
336
|
+
discardHeld("abort");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function handleConnected(g: number, h: Hello): void {
|
|
340
|
+
if (g !== state.generation) {
|
|
341
|
+
emit({ kind: "stale_event", type: "connected", generation: g });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
hello = h;
|
|
345
|
+
adopted = !h.resumed;
|
|
346
|
+
binding = true;
|
|
347
|
+
const dropped = opts.versions.bind(h.epoch);
|
|
348
|
+
binding = false;
|
|
349
|
+
helloRun.full = dropped > 0;
|
|
350
|
+
helloRun.revalidated = false;
|
|
351
|
+
if (!h.resumed) {
|
|
352
|
+
cursor = { epoch: h.epoch, offset: h.head };
|
|
353
|
+
}
|
|
354
|
+
dispatch({ type: "connected", generation: g, now: now(), hello: h });
|
|
355
|
+
if (state.kind !== "open") {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (wasHiddenClosed) {
|
|
359
|
+
wasHiddenClosed = false;
|
|
360
|
+
emit({ kind: "reopened" });
|
|
361
|
+
}
|
|
362
|
+
if (reducerRevalidated()) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (unverified || helloRun.full) {
|
|
366
|
+
schedule("hello", helloRun.full);
|
|
367
|
+
} else if (queued !== null && inflight === null) {
|
|
368
|
+
const slot = queued;
|
|
369
|
+
queued = null;
|
|
370
|
+
run(slot);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function handleEnd(g: number, reason: StreamEnd): void {
|
|
375
|
+
if (g !== state.generation) {
|
|
376
|
+
emit({ kind: "stale_event", type: "stream_ended", generation: g });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (reason === "reset:slow" || reason === "reset:shutdown") {
|
|
380
|
+
emit({ kind: "reset", reason });
|
|
381
|
+
}
|
|
382
|
+
dropConnection(g, reason);
|
|
383
|
+
discardHeld("stream_ended");
|
|
384
|
+
dispatch({ type: "stream_ended", generation: g, now: now(), reason, rand: rand(), retryMs });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function endConnection(reason: RuntimeEnd): void {
|
|
388
|
+
if (state.kind !== "open") {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const g = state.generation;
|
|
392
|
+
dropConnection(g, reason);
|
|
393
|
+
discardHeld(reason);
|
|
394
|
+
dispatch({ type: "stream_ended", generation: g, now: now(), reason, rand: rand(), retryMs });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isReplay(frame: TransportFrame, h: Hello): boolean {
|
|
398
|
+
return (
|
|
399
|
+
frame.id !== null &&
|
|
400
|
+
h.resumed &&
|
|
401
|
+
frame.id.epoch === h.epoch &&
|
|
402
|
+
compareOffset(frame.id.offset, h.head) <= 0
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function handleFrame(g: number, tf: TransportFrame): void {
|
|
407
|
+
if (g !== state.generation || hello === null) {
|
|
408
|
+
emit({ kind: "stale_event", type: "frame", generation: g });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (!adopted && !isReplay(tf, hello)) {
|
|
412
|
+
observeNonReplay(hello);
|
|
413
|
+
}
|
|
414
|
+
const frame: Frame = { type: tf.type, data: tf.data, id: tf.id };
|
|
415
|
+
if (inflight !== null) {
|
|
416
|
+
hold(frame, tf.bytes);
|
|
417
|
+
} else {
|
|
418
|
+
deliver(frame);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function observeNonReplay(h: Hello): void {
|
|
423
|
+
adopted = true;
|
|
424
|
+
if (inflight !== null) {
|
|
425
|
+
held.push({ kind: "adopt", generation: state.generation, epoch: h.epoch, head: h.head });
|
|
426
|
+
} else {
|
|
427
|
+
adopt(h.epoch, h.head);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function adopt(epoch: string, head: string): void {
|
|
432
|
+
if (cursor?.epoch !== epoch || compareOffset(head, cursor.offset) > 0) {
|
|
433
|
+
cursor = { epoch, offset: head };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function advance(id: Cursor): void {
|
|
438
|
+
if (cursor?.epoch !== id.epoch || compareOffset(id.offset, cursor.offset) > 0) {
|
|
439
|
+
cursor = id;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function hold(frame: Frame, bytes: number): void {
|
|
444
|
+
held.push({ kind: "frame", generation: state.generation, frame, bytes });
|
|
445
|
+
heldFrames++;
|
|
446
|
+
heldBytes += bytes;
|
|
447
|
+
if (heldFrames > cfg.heldMaxFrames || heldBytes > cfg.heldMaxBytes) {
|
|
448
|
+
endConnection("hold_overflow");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function deliver(frame: Frame): void {
|
|
453
|
+
let threw = false;
|
|
454
|
+
let error: unknown;
|
|
455
|
+
try {
|
|
456
|
+
opts.onFrame(frame);
|
|
457
|
+
} catch (e: unknown) {
|
|
458
|
+
threw = true;
|
|
459
|
+
error = e;
|
|
460
|
+
}
|
|
461
|
+
if (frame.id !== null) {
|
|
462
|
+
advance(frame.id);
|
|
463
|
+
}
|
|
464
|
+
if (threw) {
|
|
465
|
+
emit({ kind: "frame_rejected", type: frame.type, error });
|
|
466
|
+
schedule("hello", false);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function discardHeld(cause: HeldDiscardCause): void {
|
|
471
|
+
if (held.length === 0) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const length = held.length;
|
|
475
|
+
held = [];
|
|
476
|
+
heldFrames = 0;
|
|
477
|
+
heldBytes = 0;
|
|
478
|
+
emit({ kind: "held_discarded", cause, length });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function drain(): void {
|
|
482
|
+
if (held.length === 0) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const entries = held;
|
|
486
|
+
held = [];
|
|
487
|
+
heldFrames = 0;
|
|
488
|
+
heldBytes = 0;
|
|
489
|
+
let dropped = 0;
|
|
490
|
+
for (const entry of entries) {
|
|
491
|
+
if (entry.generation !== state.generation) {
|
|
492
|
+
dropped++;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (entry.kind === "frame") {
|
|
496
|
+
deliver(entry.frame);
|
|
497
|
+
} else {
|
|
498
|
+
adopt(entry.epoch, entry.head);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
emit({ kind: "drain", length: entries.length, dropped });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function handleKeepalive(g: number): void {
|
|
505
|
+
if (g !== state.generation || hello === null) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (!adopted) {
|
|
509
|
+
observeNonReplay(hello);
|
|
510
|
+
}
|
|
511
|
+
if (
|
|
512
|
+
state.kind === "open" &&
|
|
513
|
+
state.hiddenAt !== null &&
|
|
514
|
+
now() - state.hiddenAt >= cfg.hiddenCloseAfterMs
|
|
515
|
+
) {
|
|
516
|
+
dispatch({ type: "hidden_timer_fired", now: now() });
|
|
517
|
+
if (stream.state().kind === "hidden_closed") {
|
|
518
|
+
wasHiddenClosed = true;
|
|
519
|
+
emit({ kind: "hidden_closed", arm: "read" });
|
|
520
|
+
}
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (opts.alive === undefined) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
beats++;
|
|
527
|
+
if (beats % everyBeats === 0) {
|
|
528
|
+
acknowledge(opts.alive.url);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function acknowledge(url: string): void {
|
|
533
|
+
const scope = aliveScope;
|
|
534
|
+
if (scope === null) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const controller = new AbortController();
|
|
538
|
+
const onScopeAbort = (): void => {
|
|
539
|
+
controller.abort();
|
|
540
|
+
};
|
|
541
|
+
scope.signal.addEventListener("abort", onScopeAbort, { once: true });
|
|
542
|
+
const timer = setTimeout(() => {
|
|
543
|
+
controller.abort(new DOMException("alive acknowledgement timed out", "TimeoutError"));
|
|
544
|
+
}, ALIVE_TIMEOUT_MS);
|
|
545
|
+
void doFetch(url, {
|
|
546
|
+
method: "POST",
|
|
547
|
+
headers: { ...opts.headers },
|
|
548
|
+
credentials: "same-origin",
|
|
549
|
+
cache: "no-store",
|
|
550
|
+
signal: controller.signal,
|
|
551
|
+
})
|
|
552
|
+
.then(
|
|
553
|
+
(response) => {
|
|
554
|
+
emit({ kind: "alive_ack", ok: response.ok, status: response.status });
|
|
555
|
+
},
|
|
556
|
+
() => {
|
|
557
|
+
if (!scope.signal.aborted) {
|
|
558
|
+
emit({ kind: "alive_ack", ok: false, status: null });
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
)
|
|
562
|
+
.finally(() => {
|
|
563
|
+
clearTimeout(timer);
|
|
564
|
+
scope.signal.removeEventListener("abort", onScopeAbort);
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function schedule(cause: RevalidateCause, full: boolean): void {
|
|
569
|
+
if (inflight !== null) {
|
|
570
|
+
const coalesces =
|
|
571
|
+
cause !== "hello" &&
|
|
572
|
+
inflight.cause !== "hello" &&
|
|
573
|
+
now() - inflight.startedAt < cfg.wakeThrottleMs;
|
|
574
|
+
if (coalesces && !full) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
queued = { cause, full: (queued?.full ?? false) || full };
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const slot: Slot = { cause, full: (queued?.full ?? false) || full };
|
|
581
|
+
queued = null;
|
|
582
|
+
run(slot);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function run(slot: Slot): void {
|
|
586
|
+
const controller = new AbortController();
|
|
587
|
+
const ctx: RevalidateContext = {
|
|
588
|
+
cause: slot.cause,
|
|
589
|
+
epoch: opts.versions.epoch(),
|
|
590
|
+
generation: state.generation,
|
|
591
|
+
full: slot.full,
|
|
592
|
+
signal: controller.signal,
|
|
593
|
+
};
|
|
594
|
+
const current: Run = { controller, cause: slot.cause, startedAt: now(), timer: null };
|
|
595
|
+
current.timer = setTimeout(() => {
|
|
596
|
+
timeout(current);
|
|
597
|
+
}, cfg.revalidateTimeoutMs);
|
|
598
|
+
inflight = current;
|
|
599
|
+
emit({ kind: "revalidate", cause: slot.cause, full: slot.full });
|
|
600
|
+
let promise: Promise<void>;
|
|
601
|
+
try {
|
|
602
|
+
promise = Promise.resolve(opts.revalidate(ctx));
|
|
603
|
+
} catch (e: unknown) {
|
|
604
|
+
promise = Promise.reject(e instanceof Error ? e : new Error(String(e)));
|
|
605
|
+
}
|
|
606
|
+
promise.then(
|
|
607
|
+
() => {
|
|
608
|
+
settle(current, false, undefined);
|
|
609
|
+
},
|
|
610
|
+
(e: unknown) => {
|
|
611
|
+
settle(current, true, e);
|
|
612
|
+
},
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function settle(current: Run, rejected: boolean, error: unknown): void {
|
|
617
|
+
if (inflight !== current) {
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (current.timer !== null) {
|
|
621
|
+
clearTimeout(current.timer);
|
|
622
|
+
}
|
|
623
|
+
inflight = null;
|
|
624
|
+
if (!rejected) {
|
|
625
|
+
unverified = false;
|
|
626
|
+
drain();
|
|
627
|
+
} else {
|
|
628
|
+
unverified = true;
|
|
629
|
+
const ended = state.kind === "open";
|
|
630
|
+
emit({ kind: "revalidate_failed", cause: describe(error), latch: true, ended });
|
|
631
|
+
if (ended) {
|
|
632
|
+
endConnection("revalidate_failed");
|
|
633
|
+
} else {
|
|
634
|
+
drain();
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (queued !== null) {
|
|
638
|
+
const slot = queued;
|
|
639
|
+
queued = null;
|
|
640
|
+
run(slot);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function timeout(current: Run): void {
|
|
645
|
+
if (inflight !== current) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
inflight = null;
|
|
649
|
+
current.controller.abort(new DOMException("revalidate timed out", "TimeoutError"));
|
|
650
|
+
unverified = true;
|
|
651
|
+
emit({ kind: "revalidate_timeout" });
|
|
652
|
+
if (state.kind === "open") {
|
|
653
|
+
endConnection("hold_timeout");
|
|
654
|
+
} else {
|
|
655
|
+
discardHeld("hold_timeout");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function onVisibility(input: VisibilityInput): void {
|
|
660
|
+
switch (input) {
|
|
661
|
+
case "visible":
|
|
662
|
+
dispatch({ type: "visible", now: now(), rand: rand(), retryMs });
|
|
663
|
+
return;
|
|
664
|
+
case "pageshow":
|
|
665
|
+
dispatch({ type: "pageshow", now: now(), rand: rand(), retryMs });
|
|
666
|
+
return;
|
|
667
|
+
default:
|
|
668
|
+
dispatch({ type: input, now: now() });
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const stream: Stream = {
|
|
673
|
+
start() {
|
|
674
|
+
if (state.kind !== "stopped") {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const visibility = opts.visibility ?? (defaultVisibility ??= createVisibilityManager());
|
|
678
|
+
const online = opts.online ?? (defaultOnline ??= createOnlineManager());
|
|
679
|
+
unsubscribes.push(
|
|
680
|
+
adaptVisibility(
|
|
681
|
+
visibility,
|
|
682
|
+
{ visible: () => visibility.isVisible(), listen: () => () => undefined },
|
|
683
|
+
onVisibility,
|
|
684
|
+
),
|
|
685
|
+
online.subscribe((value) => {
|
|
686
|
+
dispatch({ type: value ? "online" : "offline", now: now() });
|
|
687
|
+
}),
|
|
688
|
+
);
|
|
689
|
+
dispatch({
|
|
690
|
+
type: "start",
|
|
691
|
+
now: now(),
|
|
692
|
+
visible: visibility.isVisible(),
|
|
693
|
+
online: online.isOnline(),
|
|
694
|
+
});
|
|
695
|
+
},
|
|
696
|
+
stop() {
|
|
697
|
+
dispatch({ type: "stop" });
|
|
698
|
+
for (const unsubscribe of unsubscribes) {
|
|
699
|
+
unsubscribe();
|
|
700
|
+
}
|
|
701
|
+
unsubscribes = [];
|
|
702
|
+
if (inflight !== null) {
|
|
703
|
+
const current = inflight;
|
|
704
|
+
inflight = null;
|
|
705
|
+
if (current.timer !== null) {
|
|
706
|
+
clearTimeout(current.timer);
|
|
707
|
+
}
|
|
708
|
+
current.controller.abort(new DOMException("stream stopped", "AbortError"));
|
|
709
|
+
}
|
|
710
|
+
queued = null;
|
|
711
|
+
},
|
|
712
|
+
reconnect() {
|
|
713
|
+
dispatch({ type: "reconnect", now: now() });
|
|
714
|
+
},
|
|
715
|
+
state: () => state,
|
|
716
|
+
cursor: () => cursor,
|
|
717
|
+
resetCursor() {
|
|
718
|
+
cursor = null;
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
internals.set(stream, {
|
|
722
|
+
revalidate: schedule,
|
|
723
|
+
queueFull() {
|
|
724
|
+
queued = { cause: queued?.cause ?? "hello", full: true };
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
return stream;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** The host-side controls of a stream created by createStream. Throws on a foreign object. */
|
|
731
|
+
export function streamInternals(stream: Stream): StreamInternals {
|
|
732
|
+
const controls = internals.get(stream);
|
|
733
|
+
if (controls === undefined) {
|
|
734
|
+
throw new TypeError("streamInternals: stream was not created by createStream");
|
|
735
|
+
}
|
|
736
|
+
return controls;
|
|
737
|
+
}
|