@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/fold.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { OnlineSource } from "./online.js";
|
|
2
|
+
import type { VisibilityEvent, VisibilityInput, VisibilitySource } from "./visibility.js";
|
|
3
|
+
|
|
4
|
+
export interface TabState {
|
|
5
|
+
readonly visible: boolean;
|
|
6
|
+
readonly online: boolean;
|
|
7
|
+
/** A freshly attached tab may earn one pageshow; cleared by its first. */
|
|
8
|
+
readonly pageshowPending: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type FoldInput =
|
|
12
|
+
| {
|
|
13
|
+
readonly type: "attach";
|
|
14
|
+
readonly tabId: string;
|
|
15
|
+
readonly visible: boolean;
|
|
16
|
+
readonly online: boolean;
|
|
17
|
+
}
|
|
18
|
+
| { readonly type: "visibility"; readonly tabId: string; readonly ev: VisibilityInput }
|
|
19
|
+
| { readonly type: "network"; readonly tabId: string; readonly online: boolean }
|
|
20
|
+
/** Detach, port close, expiry: the tab leaves as if by pagehide. */
|
|
21
|
+
| { readonly type: "remove"; readonly tabId: string };
|
|
22
|
+
|
|
23
|
+
export type FoldOutput = "visible" | "hidden" | "pageshow" | "online" | "offline";
|
|
24
|
+
|
|
25
|
+
export interface FoldState {
|
|
26
|
+
readonly tabs: ReadonlyMap<string, TabState>;
|
|
27
|
+
/** The latest network report from any tab, seeded by the host. */
|
|
28
|
+
readonly online: boolean;
|
|
29
|
+
/** Whether a pageshow was emitted since the fold last became visible. */
|
|
30
|
+
readonly pageshown: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createFoldState(online: boolean): FoldState {
|
|
34
|
+
return { tabs: new Map(), online, pageshown: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The profile reads visible while any attached tab does. */
|
|
38
|
+
export function anyVisible(state: FoldState): boolean {
|
|
39
|
+
for (const tab of state.tabs.values()) {
|
|
40
|
+
if (tab.visible) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Folded {
|
|
48
|
+
readonly state: FoldState;
|
|
49
|
+
readonly events: FoldOutput[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Folds one tab's platform event into the profile's, pure over the tab set. Visibility events
|
|
54
|
+
* precede network events within one step, the order a single tab would report them in.
|
|
55
|
+
*/
|
|
56
|
+
export function foldTabs(state: FoldState, input: FoldInput): Folded {
|
|
57
|
+
const tabs = new Map(state.tabs);
|
|
58
|
+
const visibilityEvents: FoldOutput[] = [];
|
|
59
|
+
const networkEvents: FoldOutput[] = [];
|
|
60
|
+
const before = anyVisible(state);
|
|
61
|
+
let online = state.online;
|
|
62
|
+
let pageshown = state.pageshown;
|
|
63
|
+
|
|
64
|
+
function report(value: boolean): void {
|
|
65
|
+
if (value !== online) {
|
|
66
|
+
online = value;
|
|
67
|
+
networkEvents.push(value ? "online" : "offline");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
switch (input.type) {
|
|
72
|
+
case "attach":
|
|
73
|
+
tabs.set(input.tabId, {
|
|
74
|
+
visible: input.visible,
|
|
75
|
+
online: input.online,
|
|
76
|
+
pageshowPending: true,
|
|
77
|
+
});
|
|
78
|
+
report(input.online);
|
|
79
|
+
break;
|
|
80
|
+
case "network": {
|
|
81
|
+
const tab = tabs.get(input.tabId);
|
|
82
|
+
if (tab !== undefined) {
|
|
83
|
+
tabs.set(input.tabId, { ...tab, online: input.online });
|
|
84
|
+
}
|
|
85
|
+
report(input.online);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "remove":
|
|
89
|
+
tabs.delete(input.tabId);
|
|
90
|
+
break;
|
|
91
|
+
case "visibility": {
|
|
92
|
+
const tab = tabs.get(input.tabId);
|
|
93
|
+
if (tab === undefined) {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
switch (input.ev) {
|
|
97
|
+
case "visible":
|
|
98
|
+
tabs.set(input.tabId, { ...tab, visible: true });
|
|
99
|
+
break;
|
|
100
|
+
case "hidden":
|
|
101
|
+
tabs.set(input.tabId, { ...tab, visible: false });
|
|
102
|
+
break;
|
|
103
|
+
case "pagehide":
|
|
104
|
+
tabs.delete(input.tabId);
|
|
105
|
+
break;
|
|
106
|
+
case "pageshow":
|
|
107
|
+
if (tab.visible && tab.pageshowPending && !pageshown) {
|
|
108
|
+
pageshown = true;
|
|
109
|
+
visibilityEvents.push("pageshow");
|
|
110
|
+
}
|
|
111
|
+
tabs.set(input.tabId, { ...tab, pageshowPending: false });
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const after = anyVisible({ tabs, online, pageshown });
|
|
119
|
+
if (after && !before) {
|
|
120
|
+
visibilityEvents.unshift("visible");
|
|
121
|
+
pageshown = visibilityEvents.includes("pageshow");
|
|
122
|
+
} else if (!after && before) {
|
|
123
|
+
visibilityEvents.push("hidden");
|
|
124
|
+
pageshown = false;
|
|
125
|
+
}
|
|
126
|
+
return { state: { tabs, online, pageshown }, events: [...visibilityEvents, ...networkEvents] };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type Listener<T> = (value: T) => void;
|
|
130
|
+
|
|
131
|
+
function listenable<T>(): { readonly listeners: Set<Listener<T>>; emit(value: T): void } {
|
|
132
|
+
const listeners = new Set<Listener<T>>();
|
|
133
|
+
return {
|
|
134
|
+
listeners,
|
|
135
|
+
emit(value) {
|
|
136
|
+
for (const cb of [...listeners]) {
|
|
137
|
+
cb(value);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A VisibilitySource over attached tabs, fed by the worker host. */
|
|
144
|
+
export interface ProfileVisibilitySource extends VisibilitySource {
|
|
145
|
+
attach(tabId: string, visible: boolean): void;
|
|
146
|
+
detach(tabId: string): void;
|
|
147
|
+
report(tabId: string, ev: VisibilityInput): void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createProfileVisibilitySource(): ProfileVisibilitySource {
|
|
151
|
+
let state = createFoldState(true);
|
|
152
|
+
const out = listenable<VisibilityEvent>();
|
|
153
|
+
|
|
154
|
+
function apply(input: FoldInput): void {
|
|
155
|
+
const folded = foldTabs(state, input);
|
|
156
|
+
state = folded.state;
|
|
157
|
+
for (const ev of folded.events) {
|
|
158
|
+
if (ev === "visible" || ev === "hidden" || ev === "pageshow") {
|
|
159
|
+
out.emit(ev);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
visible: () => anyVisible(state),
|
|
166
|
+
listen(cb) {
|
|
167
|
+
out.listeners.add(cb);
|
|
168
|
+
return () => {
|
|
169
|
+
out.listeners.delete(cb);
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
attach(tabId, visible) {
|
|
173
|
+
apply({ type: "attach", tabId, visible, online: true });
|
|
174
|
+
},
|
|
175
|
+
detach(tabId) {
|
|
176
|
+
apply({ type: "remove", tabId });
|
|
177
|
+
},
|
|
178
|
+
report(tabId, ev) {
|
|
179
|
+
apply({ type: "visibility", tabId, ev });
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** An OnlineSource over the tabs' forwarded network events, seeded from the worker's own reading. */
|
|
185
|
+
export interface ProfileOnlineSource extends OnlineSource {
|
|
186
|
+
attach(tabId: string, online: boolean): void;
|
|
187
|
+
detach(tabId: string): void;
|
|
188
|
+
report(tabId: string, online: boolean): void;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function createProfileOnlineSource(seed: boolean): ProfileOnlineSource {
|
|
192
|
+
let state = createFoldState(seed);
|
|
193
|
+
const out = listenable<boolean>();
|
|
194
|
+
|
|
195
|
+
function apply(input: FoldInput): void {
|
|
196
|
+
const folded = foldTabs(state, input);
|
|
197
|
+
state = folded.state;
|
|
198
|
+
for (const ev of folded.events) {
|
|
199
|
+
if (ev === "online") {
|
|
200
|
+
out.emit(true);
|
|
201
|
+
} else if (ev === "offline") {
|
|
202
|
+
out.emit(false);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
online: () => state.online,
|
|
209
|
+
listen(cb) {
|
|
210
|
+
out.listeners.add(cb);
|
|
211
|
+
return () => {
|
|
212
|
+
out.listeners.delete(cb);
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
attach(tabId, online) {
|
|
216
|
+
apply({ type: "attach", tabId, visible: false, online });
|
|
217
|
+
},
|
|
218
|
+
detach(tabId) {
|
|
219
|
+
apply({ type: "remove", tabId });
|
|
220
|
+
},
|
|
221
|
+
report(tabId, online) {
|
|
222
|
+
apply({ type: "network", tabId, online });
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DEFAULT_KEEPALIVE_MS,
|
|
3
|
+
DEFAULT_RETRY_MS,
|
|
4
|
+
DEFAULT_TIMING,
|
|
5
|
+
DIGEST_MAX_SUBJECTS,
|
|
6
|
+
KEEPALIVE_EVENT,
|
|
7
|
+
MAX_FRAME_BYTES,
|
|
8
|
+
MAX_OFFSET,
|
|
9
|
+
MAX_OFFSET_STRING,
|
|
10
|
+
WIRE,
|
|
11
|
+
resolveTiming,
|
|
12
|
+
watchdogMs,
|
|
13
|
+
} from "./timing.js";
|
|
14
|
+
export type { TimingConfig } from "./timing.js";
|
|
15
|
+
export {
|
|
16
|
+
HELLO_EVENT,
|
|
17
|
+
RESERVED_PREFIX,
|
|
18
|
+
RESET_EVENT,
|
|
19
|
+
compareOffset,
|
|
20
|
+
cursorToString,
|
|
21
|
+
parseCursor,
|
|
22
|
+
parseResetReason,
|
|
23
|
+
validateHello,
|
|
24
|
+
} from "./wire.js";
|
|
25
|
+
export type { Cursor, Hello, HelloRefusal, HelloResult, ResetReason } from "./wire.js";
|
|
26
|
+
export { createParser } from "./parser.js";
|
|
27
|
+
export type { ParsedEvent, Parser, ParserHandlers, ParserOptions } from "./parser.js";
|
|
28
|
+
export { initialState, jitter, reduce } from "./reducer.js";
|
|
29
|
+
export type {
|
|
30
|
+
ClientEvent,
|
|
31
|
+
ClientState,
|
|
32
|
+
ConnectFailure,
|
|
33
|
+
Effect,
|
|
34
|
+
Reduction,
|
|
35
|
+
StreamEnd,
|
|
36
|
+
Timer,
|
|
37
|
+
} from "./reducer.js";
|
|
38
|
+
export type { HeldDiscardCause, LifecycleEvent, RevalidateCause } from "./lifecycle.js";
|
|
39
|
+
export {
|
|
40
|
+
adaptVisibility,
|
|
41
|
+
createDOMVisibilitySource,
|
|
42
|
+
createVisibilityManager,
|
|
43
|
+
} from "./visibility.js";
|
|
44
|
+
export type {
|
|
45
|
+
VisibilityEvent,
|
|
46
|
+
VisibilityInput,
|
|
47
|
+
VisibilityManager,
|
|
48
|
+
VisibilitySource,
|
|
49
|
+
} from "./visibility.js";
|
|
50
|
+
export { createDOMOnlineSource, createOnlineManager } from "./online.js";
|
|
51
|
+
export type { OnlineManager, OnlineSource } from "./online.js";
|
|
52
|
+
export { bindListener, createVersionMap, staleListener } from "./versions.js";
|
|
53
|
+
export type { Held, Subject, VersionMap, VersionMapOptions } from "./versions.js";
|
|
54
|
+
export { DigestStatusError, createDigestClient } from "./digest.js";
|
|
55
|
+
export type {
|
|
56
|
+
DigestClient,
|
|
57
|
+
DigestClientOptions,
|
|
58
|
+
DigestResult,
|
|
59
|
+
DigestSnapshot,
|
|
60
|
+
Removed,
|
|
61
|
+
State,
|
|
62
|
+
} from "./digest.js";
|
|
63
|
+
export { createStream } from "./stream.js";
|
|
64
|
+
export type { Frame, RevalidateContext, Stream, StreamOptions } from "./stream.js";
|
|
65
|
+
export type {
|
|
66
|
+
Connection,
|
|
67
|
+
ConnectOptions,
|
|
68
|
+
TransportCallbacks,
|
|
69
|
+
TransportFrame,
|
|
70
|
+
} from "./transport.js";
|
|
71
|
+
export {
|
|
72
|
+
anyVisible,
|
|
73
|
+
createFoldState,
|
|
74
|
+
createProfileOnlineSource,
|
|
75
|
+
createProfileVisibilitySource,
|
|
76
|
+
foldTabs,
|
|
77
|
+
} from "./fold.js";
|
|
78
|
+
export type {
|
|
79
|
+
FoldInput,
|
|
80
|
+
FoldOutput,
|
|
81
|
+
FoldState,
|
|
82
|
+
ProfileOnlineSource,
|
|
83
|
+
ProfileVisibilitySource,
|
|
84
|
+
TabState,
|
|
85
|
+
} from "./fold.js";
|
|
86
|
+
export { isTabMessage, isWorkerMessage } from "./port-protocol.js";
|
|
87
|
+
export type {
|
|
88
|
+
DetachCause,
|
|
89
|
+
PortRevalidateContext,
|
|
90
|
+
TabToWorker,
|
|
91
|
+
WorkerToTab,
|
|
92
|
+
} from "./port-protocol.js";
|
|
93
|
+
export { createWorkerHost } from "./worker.js";
|
|
94
|
+
export type { DigestVerdict, TabSet, WorkerHost, WorkerHostOptions } from "./worker.js";
|
|
95
|
+
export { attachToWorker } from "./tab.js";
|
|
96
|
+
export type {
|
|
97
|
+
AttachOptions,
|
|
98
|
+
SharedWorkerLike,
|
|
99
|
+
TabAttachment,
|
|
100
|
+
TabFallback,
|
|
101
|
+
TabRevalidateContext,
|
|
102
|
+
} from "./tab.js";
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ClientState, ConnectFailure } from "./reducer.js";
|
|
2
|
+
import type { Subject } from "./versions.js";
|
|
3
|
+
import type { ResetReason } from "./wire.js";
|
|
4
|
+
|
|
5
|
+
export type RevalidateCause = "visible" | "pageshow" | "online" | "hello";
|
|
6
|
+
|
|
7
|
+
export type HeldDiscardCause =
|
|
8
|
+
"abort" | "stream_ended" | "hold_timeout" | "hold_overflow" | "revalidate_failed";
|
|
9
|
+
|
|
10
|
+
/** Every record the observability feed (`onLifecycle`) can carry. Nothing branches on one. */
|
|
11
|
+
export type LifecycleEvent =
|
|
12
|
+
| { kind: "state"; from: ClientState["kind"]; to: ClientState["kind"]; generation: number }
|
|
13
|
+
| {
|
|
14
|
+
kind: "hello";
|
|
15
|
+
verdict: string;
|
|
16
|
+
wire: number;
|
|
17
|
+
resumed: boolean;
|
|
18
|
+
epoch: string;
|
|
19
|
+
floor: string;
|
|
20
|
+
head: string;
|
|
21
|
+
}
|
|
22
|
+
| { kind: "watchdog"; sinceLastByteMs: number }
|
|
23
|
+
| { kind: "connect_failed"; reason: ConnectFailure }
|
|
24
|
+
| { kind: "reset"; reason: ResetReason }
|
|
25
|
+
| { kind: "stale_event"; type: string; generation: number }
|
|
26
|
+
| { kind: "drain"; length: number; dropped: number }
|
|
27
|
+
| { kind: "held_discarded"; cause: HeldDiscardCause; length: number }
|
|
28
|
+
| { kind: "revalidate_timeout" }
|
|
29
|
+
| { kind: "revalidate_failed"; cause: string; latch: boolean; ended: boolean }
|
|
30
|
+
| { kind: "wire_unsupported"; wire: number | null }
|
|
31
|
+
| { kind: "stale_stamp"; subject: Subject; epoch: string }
|
|
32
|
+
| { kind: "revalidate"; cause: RevalidateCause; full: boolean }
|
|
33
|
+
| { kind: "digest"; changed: number; removed: number; mustRefetch: boolean }
|
|
34
|
+
| { kind: "hidden_closed"; arm: "timer" | "read" }
|
|
35
|
+
| { kind: "reopened" }
|
|
36
|
+
| { kind: "alive_ack"; ok: boolean; status: number | null }
|
|
37
|
+
| { kind: "unknown_frame"; type: string }
|
|
38
|
+
| { kind: "bad_cursor"; id: string }
|
|
39
|
+
| { kind: "frame_rejected"; type: string; error: unknown }
|
|
40
|
+
| { kind: "worker_spawned" }
|
|
41
|
+
| { kind: "worker_dead"; sinceLastPortMessageMs: number }
|
|
42
|
+
| { kind: "worker_unavailable"; cause: "silent" | "error" }
|
|
43
|
+
| { kind: "worker_recovered" }
|
|
44
|
+
| { kind: "auth_lost"; route: string; status: number }
|
|
45
|
+
| { kind: "tab_attached"; replaced: boolean; state: ClientState["kind"] }
|
|
46
|
+
| { kind: "tab_detached"; cause: string }
|
|
47
|
+
| { kind: "port_expired"; sinceLastAckMs: number }
|
|
48
|
+
| { kind: "port_repaired" };
|
package/src/online.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export interface OnlineSource {
|
|
2
|
+
online(): boolean;
|
|
3
|
+
listen(cb: (online: boolean) => void): () => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface OnlineManager {
|
|
7
|
+
isOnline(): boolean;
|
|
8
|
+
/** The source is listened to from the first subscriber to the last. */
|
|
9
|
+
subscribe(listener: (online: boolean) => void): () => void;
|
|
10
|
+
/** Pins the value and stops listening: emits it once, then nothing. undefined clears the pin. */
|
|
11
|
+
setOnline(value: boolean | undefined): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Creates the manager over `source`, defaulting to navigator.onLine and the window events. */
|
|
15
|
+
export function createOnlineManager(source: OnlineSource = createDOMOnlineSource()): OnlineManager {
|
|
16
|
+
const listeners = new Set<(online: boolean) => void>();
|
|
17
|
+
let pinned: boolean | undefined;
|
|
18
|
+
let unlisten: (() => void) | null = null;
|
|
19
|
+
|
|
20
|
+
function forward(online: boolean): void {
|
|
21
|
+
for (const listener of [...listeners]) {
|
|
22
|
+
listener(online);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function listenIfNeeded(): void {
|
|
27
|
+
if (unlisten === null && listeners.size > 0 && pinned === undefined) {
|
|
28
|
+
unlisten = source.listen(forward);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stopListening(): void {
|
|
33
|
+
unlisten?.();
|
|
34
|
+
unlisten = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
isOnline: () => pinned ?? source.online(),
|
|
39
|
+
subscribe(listener) {
|
|
40
|
+
listeners.add(listener);
|
|
41
|
+
listenIfNeeded();
|
|
42
|
+
return () => {
|
|
43
|
+
listeners.delete(listener);
|
|
44
|
+
if (listeners.size === 0) {
|
|
45
|
+
stopListening();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
setOnline(value) {
|
|
50
|
+
pinned = value;
|
|
51
|
+
if (value === undefined) {
|
|
52
|
+
listenIfNeeded();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
stopListening();
|
|
56
|
+
forward(value);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read through globalThis so the module typechecks under a WebWorker lib, where window is absent.
|
|
62
|
+
interface DOMOnlineGlobals {
|
|
63
|
+
readonly navigator?: { readonly onLine?: boolean };
|
|
64
|
+
readonly window?: EventTarget;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** navigator.onLine is a hint: true can mean no route, so the value is only ever a reason to try. */
|
|
68
|
+
export function createDOMOnlineSource(): OnlineSource {
|
|
69
|
+
const dom = globalThis as DOMOnlineGlobals;
|
|
70
|
+
return {
|
|
71
|
+
online: () => dom.navigator?.onLine !== false,
|
|
72
|
+
listen(cb) {
|
|
73
|
+
const win = dom.window;
|
|
74
|
+
if (win === undefined) {
|
|
75
|
+
return () => undefined;
|
|
76
|
+
}
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const opts = { signal: controller.signal };
|
|
79
|
+
win.addEventListener(
|
|
80
|
+
"online",
|
|
81
|
+
() => {
|
|
82
|
+
cb(true);
|
|
83
|
+
},
|
|
84
|
+
opts,
|
|
85
|
+
);
|
|
86
|
+
win.addEventListener(
|
|
87
|
+
"offline",
|
|
88
|
+
() => {
|
|
89
|
+
cb(false);
|
|
90
|
+
},
|
|
91
|
+
opts,
|
|
92
|
+
);
|
|
93
|
+
return () => {
|
|
94
|
+
controller.abort();
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Parse loop follows rexxars/eventsource-parser (MIT): https://github.com/rexxars/eventsource-parser
|
|
2
|
+
|
|
3
|
+
/** One dispatched frame. `id` is this frame's field or null; `lastEventId` is the sticky WHATWG value. */
|
|
4
|
+
export interface ParsedEvent {
|
|
5
|
+
readonly type: string;
|
|
6
|
+
readonly data: string;
|
|
7
|
+
readonly id: string | null;
|
|
8
|
+
readonly lastEventId: string;
|
|
9
|
+
/** Encoded bytes of the frame as received, terminating blank line included. */
|
|
10
|
+
readonly bytes: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ParserHandlers {
|
|
14
|
+
readonly onEvent: (event: ParsedEvent) => void;
|
|
15
|
+
readonly onRetry?: (ms: number) => void;
|
|
16
|
+
readonly onComment?: () => void;
|
|
17
|
+
/** Called once when a frame exceeds maxBufferBytes; the parser then ignores every feed until reset(). */
|
|
18
|
+
readonly onOverflow?: (bytes: number) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ParserOptions {
|
|
22
|
+
readonly maxBufferBytes: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Parser {
|
|
26
|
+
feed(chunk: Uint8Array): void;
|
|
27
|
+
/** Returns the parser to its construction state; a partial event is discarded. */
|
|
28
|
+
reset(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const LF = 0x0a;
|
|
32
|
+
const CR = 0x0d;
|
|
33
|
+
const DIGITS_RE = /^[0-9]+$/;
|
|
34
|
+
|
|
35
|
+
/** Creates a WHATWG event-stream parser over raw bytes. */
|
|
36
|
+
export function createParser(handlers: ParserHandlers, opts: ParserOptions): Parser {
|
|
37
|
+
const { onEvent, onRetry, onComment, onOverflow } = handlers;
|
|
38
|
+
const maxBufferBytes = opts.maxBufferBytes;
|
|
39
|
+
|
|
40
|
+
let decoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
41
|
+
let lineParts: string[] = [];
|
|
42
|
+
let pendingCR = false;
|
|
43
|
+
let atStart = true;
|
|
44
|
+
let frameBytes = 0;
|
|
45
|
+
let overflowed = false;
|
|
46
|
+
|
|
47
|
+
let dataLines: string[] = [];
|
|
48
|
+
let typeBuffer = "";
|
|
49
|
+
let frameId: string | null = null;
|
|
50
|
+
let lastEventId = "";
|
|
51
|
+
|
|
52
|
+
function dispatch(): void {
|
|
53
|
+
if (frameId !== null) {
|
|
54
|
+
lastEventId = frameId;
|
|
55
|
+
}
|
|
56
|
+
if (dataLines.length > 0) {
|
|
57
|
+
const type = typeBuffer === "" ? "message" : typeBuffer;
|
|
58
|
+
onEvent({ type, data: dataLines.join("\n"), id: frameId, lastEventId, bytes: frameBytes });
|
|
59
|
+
}
|
|
60
|
+
dataLines = [];
|
|
61
|
+
typeBuffer = "";
|
|
62
|
+
frameId = null;
|
|
63
|
+
frameBytes = 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function processField(field: string, value: string): void {
|
|
67
|
+
switch (field) {
|
|
68
|
+
case "event":
|
|
69
|
+
typeBuffer = value;
|
|
70
|
+
break;
|
|
71
|
+
case "data":
|
|
72
|
+
dataLines.push(value);
|
|
73
|
+
break;
|
|
74
|
+
case "id":
|
|
75
|
+
if (!value.includes("\0")) {
|
|
76
|
+
frameId = value;
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
case "retry":
|
|
80
|
+
if (DIGITS_RE.test(value)) {
|
|
81
|
+
onRetry?.(parseInt(value, 10));
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function processLine(line: string): void {
|
|
90
|
+
if (line === "") {
|
|
91
|
+
dispatch();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (line.charCodeAt(0) === 0x3a) {
|
|
95
|
+
onComment?.();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const colon = line.indexOf(":");
|
|
99
|
+
if (colon === -1) {
|
|
100
|
+
processField(line, "");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const valueStart = line.charCodeAt(colon + 1) === 0x20 ? colon + 2 : colon + 1;
|
|
104
|
+
processField(line.slice(0, colon), line.slice(valueStart));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function endLine(bytes: Uint8Array): void {
|
|
108
|
+
let text: string;
|
|
109
|
+
if (lineParts.length === 0) {
|
|
110
|
+
text = decoder.decode(bytes);
|
|
111
|
+
} else {
|
|
112
|
+
lineParts.push(decoder.decode(bytes));
|
|
113
|
+
text = lineParts.join("");
|
|
114
|
+
lineParts = [];
|
|
115
|
+
}
|
|
116
|
+
if (atStart) {
|
|
117
|
+
atStart = false;
|
|
118
|
+
if (text.charCodeAt(0) === 0xfeff) {
|
|
119
|
+
text = text.slice(1);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
processLine(text);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function overflow(): void {
|
|
126
|
+
overflowed = true;
|
|
127
|
+
lineParts = [];
|
|
128
|
+
dataLines = [];
|
|
129
|
+
onOverflow?.(frameBytes);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function feed(chunk: Uint8Array): void {
|
|
133
|
+
if (overflowed || chunk.length === 0) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
let pos = 0;
|
|
137
|
+
if (pendingCR) {
|
|
138
|
+
pendingCR = false;
|
|
139
|
+
if (chunk[0] === LF) {
|
|
140
|
+
pos = 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let lineStart = pos;
|
|
144
|
+
while (pos < chunk.length) {
|
|
145
|
+
const b = chunk[pos];
|
|
146
|
+
if (b !== LF && b !== CR) {
|
|
147
|
+
pos++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
let terminator = 1;
|
|
151
|
+
if (b === CR) {
|
|
152
|
+
if (pos + 1 < chunk.length) {
|
|
153
|
+
if (chunk[pos + 1] === LF) {
|
|
154
|
+
terminator = 2;
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
pendingCR = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
frameBytes += pos - lineStart + terminator;
|
|
161
|
+
if (frameBytes > maxBufferBytes) {
|
|
162
|
+
overflow();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
endLine(chunk.subarray(lineStart, pos));
|
|
166
|
+
pos += terminator;
|
|
167
|
+
lineStart = pos;
|
|
168
|
+
}
|
|
169
|
+
if (lineStart < chunk.length) {
|
|
170
|
+
frameBytes += chunk.length - lineStart;
|
|
171
|
+
if (frameBytes > maxBufferBytes) {
|
|
172
|
+
overflow();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
lineParts.push(decoder.decode(chunk.subarray(lineStart), { stream: true }));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function reset(): void {
|
|
180
|
+
decoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
181
|
+
lineParts = [];
|
|
182
|
+
pendingCR = false;
|
|
183
|
+
atStart = true;
|
|
184
|
+
frameBytes = 0;
|
|
185
|
+
overflowed = false;
|
|
186
|
+
dataLines = [];
|
|
187
|
+
typeBuffer = "";
|
|
188
|
+
frameId = null;
|
|
189
|
+
lastEventId = "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { feed, reset };
|
|
193
|
+
}
|