@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/transport.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createParser } from "./parser.js";
|
|
2
|
+
import type { ConnectFailure, StreamEnd } from "./reducer.js";
|
|
3
|
+
import type { TimingConfig } from "./timing.js";
|
|
4
|
+
import {
|
|
5
|
+
type Cursor,
|
|
6
|
+
HELLO_EVENT,
|
|
7
|
+
type Hello,
|
|
8
|
+
RESERVED_PREFIX,
|
|
9
|
+
RESET_EVENT,
|
|
10
|
+
cursorToString,
|
|
11
|
+
parseCursor,
|
|
12
|
+
parseResetReason,
|
|
13
|
+
validateHello,
|
|
14
|
+
} from "./wire.js";
|
|
15
|
+
|
|
16
|
+
/** An application frame as the transport hands it up: the id already parsed as a cursor. */
|
|
17
|
+
export interface TransportFrame {
|
|
18
|
+
readonly type: string;
|
|
19
|
+
readonly data: string;
|
|
20
|
+
readonly id: Cursor | null;
|
|
21
|
+
/** Encoded bytes of the frame as received; feeds the hold's byte bound. */
|
|
22
|
+
readonly bytes: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What one connection attempt reports. Every callback carries the attempt's generation.
|
|
27
|
+
* Exactly one of onConnected-then-onEnd, onConnectFailed, or nothing (after abort) happens.
|
|
28
|
+
*/
|
|
29
|
+
export interface TransportCallbacks {
|
|
30
|
+
readonly onByte: (generation: number) => void;
|
|
31
|
+
readonly onConnected: (generation: number, hello: Hello) => void;
|
|
32
|
+
readonly onConnectFailed: (generation: number, reason: ConnectFailure) => void;
|
|
33
|
+
readonly onFrame: (generation: number, frame: TransportFrame) => void;
|
|
34
|
+
readonly onKeepalive: (generation: number) => void;
|
|
35
|
+
readonly onUnknownFrame: (generation: number, type: string) => void;
|
|
36
|
+
readonly onBadCursor: (generation: number, id: string) => void;
|
|
37
|
+
readonly onRetry: (generation: number, ms: number) => void;
|
|
38
|
+
readonly onEnd: (generation: number, reason: StreamEnd) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ConnectOptions {
|
|
42
|
+
readonly url: string;
|
|
43
|
+
readonly fetch: typeof fetch;
|
|
44
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
45
|
+
readonly cursor: Cursor | null;
|
|
46
|
+
readonly minWire: number;
|
|
47
|
+
readonly maxWire: number;
|
|
48
|
+
readonly timing: TimingConfig;
|
|
49
|
+
readonly generation: number;
|
|
50
|
+
readonly callbacks: TransportCallbacks;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Connection {
|
|
54
|
+
/** Ends the attempt; nothing is dispatched afterwards. */
|
|
55
|
+
abort(reason: string): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const EVENT_STREAM = "text/event-stream";
|
|
59
|
+
|
|
60
|
+
function mediaType(contentType: string | null): string {
|
|
61
|
+
if (contentType === null) {
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
const semicolon = contentType.indexOf(";");
|
|
65
|
+
return (semicolon === -1 ? contentType : contentType.slice(0, semicolon)).trim().toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Opens one stream attempt: fetch, the two connect-phase deadlines, and the frame translation. */
|
|
69
|
+
export function connect(opts: ConnectOptions): Connection {
|
|
70
|
+
const { generation: g, callbacks: cb, timing } = opts;
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
let abortReason: string | null = null;
|
|
73
|
+
let finished = false;
|
|
74
|
+
let helloSeen = false;
|
|
75
|
+
let keepaliveEvent = "";
|
|
76
|
+
let deadline: ReturnType<typeof setTimeout> | null = null;
|
|
77
|
+
|
|
78
|
+
function clearDeadline(): void {
|
|
79
|
+
if (deadline !== null) {
|
|
80
|
+
clearTimeout(deadline);
|
|
81
|
+
deadline = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function abort(reason: string): void {
|
|
86
|
+
abortReason ??= reason;
|
|
87
|
+
controller.abort(new DOMException(reason, "AbortError"));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const reason = (): string | null => abortReason;
|
|
91
|
+
const done = (): boolean => finished;
|
|
92
|
+
|
|
93
|
+
function fail(reason: ConnectFailure): void {
|
|
94
|
+
if (finished) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
finished = true;
|
|
98
|
+
clearDeadline();
|
|
99
|
+
abort("refused");
|
|
100
|
+
cb.onConnectFailed(g, reason);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function end(reason: StreamEnd): void {
|
|
104
|
+
if (finished) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
finished = true;
|
|
108
|
+
clearDeadline();
|
|
109
|
+
abort("ended");
|
|
110
|
+
cb.onEnd(g, reason);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const parser = createParser(
|
|
114
|
+
{
|
|
115
|
+
onEvent(ev) {
|
|
116
|
+
if (finished) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!helloSeen) {
|
|
120
|
+
if (ev.type !== HELLO_EVENT) {
|
|
121
|
+
fail({ kind: "bad_hello" });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let data: unknown;
|
|
125
|
+
try {
|
|
126
|
+
data = JSON.parse(ev.data);
|
|
127
|
+
} catch {
|
|
128
|
+
fail({ kind: "bad_hello" });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const result = validateHello(data, opts.minWire, opts.maxWire);
|
|
132
|
+
if (!result.ok) {
|
|
133
|
+
fail(result.reason);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
helloSeen = true;
|
|
137
|
+
keepaliveEvent = result.hello.keepalive_event;
|
|
138
|
+
clearDeadline();
|
|
139
|
+
cb.onConnected(g, result.hello);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (ev.type === HELLO_EVENT) {
|
|
143
|
+
end("error");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (ev.type === keepaliveEvent) {
|
|
147
|
+
cb.onKeepalive(g);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (ev.type === RESET_EVENT) {
|
|
151
|
+
end(parseResetReason(ev.data) ?? "error");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (ev.type.startsWith(RESERVED_PREFIX)) {
|
|
155
|
+
cb.onUnknownFrame(g, ev.type);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
let id: Cursor | null = null;
|
|
159
|
+
if (ev.id !== null) {
|
|
160
|
+
id = parseCursor(ev.id);
|
|
161
|
+
if (id === null) {
|
|
162
|
+
cb.onBadCursor(g, ev.id);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
cb.onFrame(g, { type: ev.type, data: ev.data, id, bytes: ev.bytes });
|
|
167
|
+
},
|
|
168
|
+
onRetry(ms) {
|
|
169
|
+
if (!finished) {
|
|
170
|
+
cb.onRetry(g, ms);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
onOverflow() {
|
|
174
|
+
end("frame_too_large");
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{ maxBufferBytes: timing.maxBufferBytes },
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
function requestHeaders(): Record<string, string> {
|
|
181
|
+
const headers: Record<string, string> = {
|
|
182
|
+
Accept: EVENT_STREAM,
|
|
183
|
+
"SSE-Wire": String(opts.maxWire),
|
|
184
|
+
};
|
|
185
|
+
if (opts.cursor !== null) {
|
|
186
|
+
headers["Last-Event-ID"] = cursorToString(opts.cursor);
|
|
187
|
+
}
|
|
188
|
+
return { ...headers, ...opts.headers };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function run(): Promise<void> {
|
|
192
|
+
deadline = setTimeout(() => {
|
|
193
|
+
abort("timeout_headers");
|
|
194
|
+
}, timing.connectTimeoutMs);
|
|
195
|
+
let response: Response;
|
|
196
|
+
try {
|
|
197
|
+
response = await opts.fetch(opts.url, {
|
|
198
|
+
headers: requestHeaders(),
|
|
199
|
+
cache: "no-store",
|
|
200
|
+
credentials: "same-origin",
|
|
201
|
+
signal: controller.signal,
|
|
202
|
+
});
|
|
203
|
+
} catch {
|
|
204
|
+
clearDeadline();
|
|
205
|
+
if (reason() === "timeout_headers") {
|
|
206
|
+
fail({ kind: "timeout_headers" });
|
|
207
|
+
} else if (reason() === null) {
|
|
208
|
+
fail({ kind: "network" });
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
clearDeadline();
|
|
213
|
+
if (reason() !== null) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (response.status !== 200) {
|
|
217
|
+
fail({ kind: "status", status: response.status });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const contentType = response.headers.get("content-type");
|
|
221
|
+
if (mediaType(contentType) !== EVENT_STREAM) {
|
|
222
|
+
fail({ kind: "content_type", value: contentType ?? "" });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (response.body === null) {
|
|
226
|
+
end("eof");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
deadline = setTimeout(() => {
|
|
230
|
+
abort("timeout_hello");
|
|
231
|
+
}, timing.helloTimeoutMs);
|
|
232
|
+
const reader = response.body.getReader();
|
|
233
|
+
for (;;) {
|
|
234
|
+
let result: ReadableStreamReadResult<Uint8Array>;
|
|
235
|
+
try {
|
|
236
|
+
result = await reader.read();
|
|
237
|
+
} catch {
|
|
238
|
+
if (done()) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (reason() === "timeout_hello") {
|
|
242
|
+
fail({ kind: "timeout_hello" });
|
|
243
|
+
} else if (reason() === null) {
|
|
244
|
+
end("error");
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (done() || reason() !== null) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (result.done) {
|
|
252
|
+
end("eof");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (result.value.length === 0) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
cb.onByte(g);
|
|
259
|
+
parser.feed(result.value);
|
|
260
|
+
if (done()) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
void run();
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
abort(reason) {
|
|
270
|
+
if (finished) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
finished = true;
|
|
274
|
+
clearDeadline();
|
|
275
|
+
abort(reason);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
package/src/versions.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** A digest subject key: the application's kind and reference. */
|
|
2
|
+
export interface Subject {
|
|
3
|
+
readonly kind: string;
|
|
4
|
+
readonly ref: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** A subject with the version the client holds for it. */
|
|
8
|
+
export interface Held extends Subject {
|
|
9
|
+
readonly version: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The epoch-bound version map. Versions are opaque strings recorded after the state they
|
|
14
|
+
* stamp has been applied, never from a digest answer.
|
|
15
|
+
*/
|
|
16
|
+
export interface VersionMap {
|
|
17
|
+
/** The epoch every held version was minted under; null until bound. */
|
|
18
|
+
epoch(): string | null;
|
|
19
|
+
/** Binds the map to `epoch`; a different epoch clears every entry. Returns the dropped count. */
|
|
20
|
+
bind(epoch: string): number;
|
|
21
|
+
/**
|
|
22
|
+
* Records `version` for `subject` and returns whether the held version changed. With `epoch`:
|
|
23
|
+
* an unbound map binds to it first; a map bound to a different epoch ignores the call and
|
|
24
|
+
* reports it through onStale.
|
|
25
|
+
*/
|
|
26
|
+
observe(subject: Subject, version: string, epoch?: string): boolean;
|
|
27
|
+
forget(subject: Subject): void;
|
|
28
|
+
has(subject: Subject): boolean;
|
|
29
|
+
snapshot(): { epoch: string | null; held: Held[] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface VersionMapOptions {
|
|
33
|
+
/** Called for every stamp ignored because its epoch differs from the bound epoch. */
|
|
34
|
+
readonly onStale?: (subject: Subject, epoch: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type BindListener = (epoch: string, dropped: number) => void;
|
|
38
|
+
type StaleListener = (subject: Subject, epoch: string) => void;
|
|
39
|
+
|
|
40
|
+
interface Listeners {
|
|
41
|
+
readonly bound: Set<BindListener>;
|
|
42
|
+
readonly stale: Set<StaleListener>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const listeners = new WeakMap<VersionMap, Listeners>();
|
|
46
|
+
|
|
47
|
+
function key(subject: Subject): string {
|
|
48
|
+
return `${subject.kind}\0${subject.ref}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Epoch binding follows IMAP CONDSTORE (RFC 7162): a UIDVALIDITY change discards cached mod-sequences.
|
|
52
|
+
/** Creates an empty, unbound version map. */
|
|
53
|
+
export function createVersionMap(opts: VersionMapOptions = {}): VersionMap {
|
|
54
|
+
let epoch: string | null = null;
|
|
55
|
+
const held = new Map<string, Held>();
|
|
56
|
+
const bound = new Set<BindListener>();
|
|
57
|
+
const stale = new Set<StaleListener>();
|
|
58
|
+
|
|
59
|
+
const map: VersionMap = {
|
|
60
|
+
epoch: () => epoch,
|
|
61
|
+
bind(next) {
|
|
62
|
+
let dropped = 0;
|
|
63
|
+
if (epoch !== next) {
|
|
64
|
+
dropped = held.size;
|
|
65
|
+
held.clear();
|
|
66
|
+
epoch = next;
|
|
67
|
+
}
|
|
68
|
+
for (const listener of bound) {
|
|
69
|
+
listener(next, dropped);
|
|
70
|
+
}
|
|
71
|
+
return dropped;
|
|
72
|
+
},
|
|
73
|
+
observe(subject, version, stampEpoch) {
|
|
74
|
+
if (stampEpoch !== undefined) {
|
|
75
|
+
if (epoch === null) {
|
|
76
|
+
epoch = stampEpoch;
|
|
77
|
+
} else if (epoch !== stampEpoch) {
|
|
78
|
+
opts.onStale?.(subject, stampEpoch);
|
|
79
|
+
for (const listener of stale) {
|
|
80
|
+
listener(subject, stampEpoch);
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const k = key(subject);
|
|
86
|
+
const previous = held.get(k);
|
|
87
|
+
held.set(k, { kind: subject.kind, ref: subject.ref, version });
|
|
88
|
+
return previous?.version !== version;
|
|
89
|
+
},
|
|
90
|
+
forget(subject) {
|
|
91
|
+
held.delete(key(subject));
|
|
92
|
+
},
|
|
93
|
+
has(subject) {
|
|
94
|
+
return held.has(key(subject));
|
|
95
|
+
},
|
|
96
|
+
snapshot() {
|
|
97
|
+
return { epoch, held: [...held.values()] };
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
listeners.set(map, { bound, stale });
|
|
101
|
+
return map;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function listenersOf(map: VersionMap, caller: string): Listeners {
|
|
105
|
+
const set = listeners.get(map);
|
|
106
|
+
if (set === undefined) {
|
|
107
|
+
throw new TypeError(`${caller}: map was not created by createVersionMap`);
|
|
108
|
+
}
|
|
109
|
+
return set;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Subscribes to every bind() on a map created by createVersionMap; the runtime uses it to
|
|
114
|
+
* discharge a queued full revalidation. Returns the unsubscribe. Throws on a foreign map.
|
|
115
|
+
*/
|
|
116
|
+
export function bindListener(map: VersionMap, fn: BindListener): () => void {
|
|
117
|
+
const set = listenersOf(map, "bindListener").bound;
|
|
118
|
+
set.add(fn);
|
|
119
|
+
return () => {
|
|
120
|
+
set.delete(fn);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Subscribes to every stamp the map ignores for a foreign epoch; the runtime reports each as
|
|
126
|
+
* stale_stamp. Returns the unsubscribe. Throws on a foreign map.
|
|
127
|
+
*/
|
|
128
|
+
export function staleListener(map: VersionMap, fn: StaleListener): () => void {
|
|
129
|
+
const set = listenersOf(map, "staleListener").stale;
|
|
130
|
+
set.add(fn);
|
|
131
|
+
return () => {
|
|
132
|
+
set.delete(fn);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
export type VisibilityEvent = "visible" | "hidden" | "pagehide" | "pageshow" | "freeze" | "resume";
|
|
2
|
+
|
|
3
|
+
/** The four visibility inputs the reducer accepts; the adapter folds freeze and resume into them. */
|
|
4
|
+
export type VisibilityInput = "visible" | "hidden" | "pagehide" | "pageshow";
|
|
5
|
+
|
|
6
|
+
export interface VisibilitySource {
|
|
7
|
+
visible(): boolean;
|
|
8
|
+
listen(cb: (ev: VisibilityEvent) => void): () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface VisibilityManager {
|
|
12
|
+
isVisible(): boolean;
|
|
13
|
+
/** The source is listened to from the first subscriber to the last. */
|
|
14
|
+
subscribe(listener: (ev: VisibilityEvent) => void): () => void;
|
|
15
|
+
/** Pins the value and stops listening: emits visible or hidden once, then nothing. undefined clears the pin. */
|
|
16
|
+
setVisible(value: boolean | undefined): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Creates the manager over `source`, defaulting to the DOM. */
|
|
20
|
+
export function createVisibilityManager(
|
|
21
|
+
source: VisibilitySource = createDOMVisibilitySource(),
|
|
22
|
+
): VisibilityManager {
|
|
23
|
+
const listeners = new Set<(ev: VisibilityEvent) => void>();
|
|
24
|
+
let pinned: boolean | undefined;
|
|
25
|
+
let unlisten: (() => void) | null = null;
|
|
26
|
+
|
|
27
|
+
function forward(ev: VisibilityEvent): void {
|
|
28
|
+
for (const listener of [...listeners]) {
|
|
29
|
+
listener(ev);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function listenIfNeeded(): void {
|
|
34
|
+
if (unlisten === null && listeners.size > 0 && pinned === undefined) {
|
|
35
|
+
unlisten = source.listen(forward);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stopListening(): void {
|
|
40
|
+
unlisten?.();
|
|
41
|
+
unlisten = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
isVisible: () => pinned ?? source.visible(),
|
|
46
|
+
subscribe(listener) {
|
|
47
|
+
listeners.add(listener);
|
|
48
|
+
listenIfNeeded();
|
|
49
|
+
return () => {
|
|
50
|
+
listeners.delete(listener);
|
|
51
|
+
if (listeners.size === 0) {
|
|
52
|
+
stopListening();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
setVisible(value) {
|
|
57
|
+
pinned = value;
|
|
58
|
+
if (value === undefined) {
|
|
59
|
+
listenIfNeeded();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
stopListening();
|
|
63
|
+
forward(value ? "visible" : "hidden");
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Read through globalThis so the module typechecks under a WebWorker lib, where neither exists.
|
|
69
|
+
interface DOMVisibilityGlobals {
|
|
70
|
+
readonly document?: EventTarget & { readonly visibilityState?: string };
|
|
71
|
+
readonly window?: EventTarget;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The only reader of document and window visibility in the package. */
|
|
75
|
+
export function createDOMVisibilitySource(): VisibilitySource {
|
|
76
|
+
const dom = globalThis as DOMVisibilityGlobals;
|
|
77
|
+
const visible = (): boolean => dom.document?.visibilityState !== "hidden";
|
|
78
|
+
return {
|
|
79
|
+
visible,
|
|
80
|
+
listen(cb) {
|
|
81
|
+
const doc = dom.document;
|
|
82
|
+
const win = dom.window;
|
|
83
|
+
if (doc === undefined || win === undefined) {
|
|
84
|
+
return () => undefined;
|
|
85
|
+
}
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const opts = { signal: controller.signal };
|
|
88
|
+
const on = (target: EventTarget, type: string, ev: () => VisibilityEvent): void => {
|
|
89
|
+
target.addEventListener(
|
|
90
|
+
type,
|
|
91
|
+
() => {
|
|
92
|
+
cb(ev());
|
|
93
|
+
},
|
|
94
|
+
opts,
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
on(doc, "visibilitychange", () => (visible() ? "visible" : "hidden"));
|
|
98
|
+
on(doc, "freeze", () => "freeze");
|
|
99
|
+
on(doc, "resume", () => "resume");
|
|
100
|
+
on(win, "pagehide", () => "pagehide");
|
|
101
|
+
on(win, "pageshow", () => "pageshow");
|
|
102
|
+
return () => {
|
|
103
|
+
controller.abort();
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Maps manager events onto reducer inputs: freeze is hidden; resume and pageshow are emitted
|
|
111
|
+
* only while the source reads visible, because a restored or resumed page may still be hidden.
|
|
112
|
+
*/
|
|
113
|
+
export function adaptVisibility(
|
|
114
|
+
manager: VisibilityManager,
|
|
115
|
+
source: VisibilitySource,
|
|
116
|
+
emit: (ev: VisibilityInput) => void,
|
|
117
|
+
): () => void {
|
|
118
|
+
return manager.subscribe((ev) => {
|
|
119
|
+
switch (ev) {
|
|
120
|
+
case "freeze":
|
|
121
|
+
emit("hidden");
|
|
122
|
+
return;
|
|
123
|
+
case "resume":
|
|
124
|
+
if (source.visible()) {
|
|
125
|
+
emit("visible");
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
case "pageshow":
|
|
129
|
+
if (source.visible()) {
|
|
130
|
+
emit("pageshow");
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
default:
|
|
134
|
+
emit(ev);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { MAX_OFFSET_STRING } from "./timing.js";
|
|
2
|
+
|
|
3
|
+
/** Event name of the handshake frame the server writes once per connection. */
|
|
4
|
+
export const HELLO_EVENT = "sse:hello";
|
|
5
|
+
|
|
6
|
+
/** Event name of the frame the server writes immediately before a deliberate close. */
|
|
7
|
+
export const RESET_EVENT = "sse:reset";
|
|
8
|
+
|
|
9
|
+
/** Prefix reserved for library-owned frames; an application never publishes one. */
|
|
10
|
+
export const RESERVED_PREFIX = "sse:";
|
|
11
|
+
|
|
12
|
+
/** A resume position: the hub epoch and a decimal offset, presented as `Last-Event-ID`. */
|
|
13
|
+
export interface Cursor {
|
|
14
|
+
readonly epoch: string;
|
|
15
|
+
readonly offset: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The hello frame's payload after validation. `resumed === true` is the only branch point. */
|
|
19
|
+
export interface Hello {
|
|
20
|
+
readonly wire: number;
|
|
21
|
+
readonly epoch: string;
|
|
22
|
+
readonly floor: string;
|
|
23
|
+
readonly head: string;
|
|
24
|
+
readonly resumed: boolean;
|
|
25
|
+
readonly verdict: string;
|
|
26
|
+
readonly keepalive_ms: number;
|
|
27
|
+
readonly keepalive_event: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Why a hello was refused; both reasons take the ordinary backoff ladder. */
|
|
31
|
+
export type HelloRefusal =
|
|
32
|
+
{ kind: "bad_hello" } | { kind: "wire_unsupported"; wire: number | null };
|
|
33
|
+
|
|
34
|
+
export type HelloResult = { ok: true; hello: Hello } | { ok: false; reason: HelloRefusal };
|
|
35
|
+
|
|
36
|
+
const EPOCH_RE = /^[0-9a-f]{16}$/;
|
|
37
|
+
const OFFSET_RE = /^(0|[1-9][0-9]*)$/;
|
|
38
|
+
const MAX_CURSOR_LENGTH = 33;
|
|
39
|
+
|
|
40
|
+
/** Orders two offsets in the wire grammar exactly, above 2^53 included. */
|
|
41
|
+
export function compareOffset(a: string, b: string): -1 | 0 | 1 {
|
|
42
|
+
const x = BigInt(a);
|
|
43
|
+
const y = BigInt(b);
|
|
44
|
+
if (x < y) {
|
|
45
|
+
return -1;
|
|
46
|
+
}
|
|
47
|
+
return x > y ? 1 : 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isOffset(value: unknown): value is string {
|
|
51
|
+
return (
|
|
52
|
+
typeof value === "string" &&
|
|
53
|
+
OFFSET_RE.test(value) &&
|
|
54
|
+
compareOffset(value, MAX_OFFSET_STRING) <= 0
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isEpoch(value: unknown): value is string {
|
|
59
|
+
return typeof value === "string" && EPOCH_RE.test(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Parses `<epoch>:<offset>`; null for anything else, a bare integer included. */
|
|
63
|
+
export function parseCursor(id: string): Cursor | null {
|
|
64
|
+
if (id.length === 0 || id.length > MAX_CURSOR_LENGTH) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const colon = id.indexOf(":");
|
|
68
|
+
if (colon === -1) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const epoch = id.slice(0, colon);
|
|
72
|
+
const offset = id.slice(colon + 1);
|
|
73
|
+
if (!isEpoch(epoch) || !isOffset(offset)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return { epoch, offset };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Renders a cursor as the `Last-Event-ID` value the server parses. */
|
|
80
|
+
export function cursorToString(cursor: Cursor): string {
|
|
81
|
+
return `${cursor.epoch}:${cursor.offset}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const BAD_HELLO: HelloResult = { ok: false, reason: { kind: "bad_hello" } };
|
|
85
|
+
|
|
86
|
+
// Structural pessimism follows Centrifugo's recovered/was_recovering: only `resumed === true` resumes.
|
|
87
|
+
/** Validates a decoded hello payload against the wire contract and the accepted revision range. */
|
|
88
|
+
export function validateHello(data: unknown, minWire: number, maxWire: number): HelloResult {
|
|
89
|
+
if (typeof data !== "object" || data === null) {
|
|
90
|
+
return BAD_HELLO;
|
|
91
|
+
}
|
|
92
|
+
const record = data as Record<string, unknown>;
|
|
93
|
+
const wire = record["wire"];
|
|
94
|
+
if (typeof wire !== "number" || !Number.isInteger(wire)) {
|
|
95
|
+
return BAD_HELLO;
|
|
96
|
+
}
|
|
97
|
+
if (wire < minWire || wire > maxWire) {
|
|
98
|
+
return { ok: false, reason: { kind: "wire_unsupported", wire } };
|
|
99
|
+
}
|
|
100
|
+
const epoch = record["epoch"];
|
|
101
|
+
const floor = record["floor"];
|
|
102
|
+
const head = record["head"];
|
|
103
|
+
const resumed = record["resumed"];
|
|
104
|
+
const keepaliveMs = record["keepalive_ms"];
|
|
105
|
+
const keepaliveEvent = record["keepalive_event"];
|
|
106
|
+
if (!isEpoch(epoch) || !isOffset(floor) || !isOffset(head)) {
|
|
107
|
+
return BAD_HELLO;
|
|
108
|
+
}
|
|
109
|
+
if (typeof resumed !== "boolean") {
|
|
110
|
+
return BAD_HELLO;
|
|
111
|
+
}
|
|
112
|
+
if (typeof keepaliveMs !== "number" || !Number.isInteger(keepaliveMs) || keepaliveMs <= 0) {
|
|
113
|
+
return BAD_HELLO;
|
|
114
|
+
}
|
|
115
|
+
if (typeof keepaliveEvent !== "string") {
|
|
116
|
+
return BAD_HELLO;
|
|
117
|
+
}
|
|
118
|
+
const verdict = record["verdict"];
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
hello: {
|
|
122
|
+
wire,
|
|
123
|
+
epoch,
|
|
124
|
+
floor,
|
|
125
|
+
head,
|
|
126
|
+
resumed,
|
|
127
|
+
verdict: typeof verdict === "string" ? verdict : "",
|
|
128
|
+
keepalive_ms: keepaliveMs,
|
|
129
|
+
keepalive_event: keepaliveEvent,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The two stream-end reasons an `sse:reset` frame can carry. */
|
|
135
|
+
export type ResetReason = "reset:slow" | "reset:shutdown";
|
|
136
|
+
|
|
137
|
+
/** Maps an `sse:reset` payload to its reason; null when the payload is not one of the two. */
|
|
138
|
+
export function parseResetReason(data: string): ResetReason | null {
|
|
139
|
+
let parsed: unknown;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(data);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const reason = (parsed as Record<string, unknown>)["reason"];
|
|
149
|
+
if (reason === "slow") {
|
|
150
|
+
return "reset:slow";
|
|
151
|
+
}
|
|
152
|
+
if (reason === "shutdown") {
|
|
153
|
+
return "reset:shutdown";
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|