@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.
@@ -0,0 +1,100 @@
1
+ import type { Removed, State } from "./digest.js";
2
+ import type { LifecycleEvent, RevalidateCause } from "./lifecycle.js";
3
+ import type { ClientState } from "./reducer.js";
4
+ import type { Frame } from "./stream.js";
5
+ import type { Subject } from "./versions.js";
6
+ import type { VisibilityInput } from "./visibility.js";
7
+ import type { Cursor } from "./wire.js";
8
+
9
+ export type DetachCause = "unload" | "respawn" | "fallback" | "logout";
10
+
11
+ /** RevalidateContext without its signal, which cannot cross a port; the tab supplies its own. */
12
+ export interface PortRevalidateContext {
13
+ readonly cause: RevalidateCause;
14
+ readonly epoch: string | null;
15
+ readonly generation: number;
16
+ readonly full: boolean;
17
+ /** The host's digest verdict for this run, when its `revalidate` body performed one. */
18
+ readonly changed?: readonly State[];
19
+ readonly removed?: readonly Removed[];
20
+ }
21
+
22
+ export type TabToWorker =
23
+ | {
24
+ readonly type: "attach";
25
+ readonly tabId: string;
26
+ readonly visible: boolean;
27
+ readonly online: boolean;
28
+ readonly hadWorker: boolean;
29
+ readonly tag: string;
30
+ }
31
+ | { readonly type: "visibility"; readonly ev: VisibilityInput }
32
+ | { readonly type: "network"; readonly online: boolean }
33
+ | { readonly type: "ping"; readonly seq: number }
34
+ | { readonly type: "detach"; readonly cause: DetachCause }
35
+ | { readonly type: "heartbeat_ack"; readonly seq: number }
36
+ | {
37
+ readonly type: "observed";
38
+ readonly subject: Subject;
39
+ readonly version: string;
40
+ readonly epoch: string;
41
+ }
42
+ | { readonly type: "reconnect"; readonly resetCursor: boolean }
43
+ | { readonly type: "set_tag"; readonly tag: string }
44
+ | { readonly type: "revalidate_done"; readonly runId: number }
45
+ | { readonly type: "revalidate_failed"; readonly runId: number; readonly cause: string }
46
+ | { readonly type: "frame_failed"; readonly generation: number; readonly cursor: Cursor | null };
47
+
48
+ export type WorkerToTab =
49
+ | { readonly type: "frame"; readonly frame: Frame; readonly generation: number }
50
+ | { readonly type: "lifecycle"; readonly event: LifecycleEvent }
51
+ | { readonly type: "heartbeat"; readonly seq: number }
52
+ | { readonly type: "pong"; readonly seq: number }
53
+ | { readonly type: "revalidate_run"; readonly runId: number; readonly ctx: PortRevalidateContext }
54
+ | { readonly type: "stale" }
55
+ | { readonly type: "state"; readonly state: ClientState };
56
+
57
+ const TAB_TYPES: ReadonlySet<string> = new Set<TabToWorker["type"]>([
58
+ "attach",
59
+ "visibility",
60
+ "network",
61
+ "ping",
62
+ "detach",
63
+ "heartbeat_ack",
64
+ "observed",
65
+ "reconnect",
66
+ "set_tag",
67
+ "revalidate_done",
68
+ "revalidate_failed",
69
+ "frame_failed",
70
+ ]);
71
+
72
+ const WORKER_TYPES: ReadonlySet<string> = new Set<WorkerToTab["type"]>([
73
+ "frame",
74
+ "lifecycle",
75
+ "heartbeat",
76
+ "pong",
77
+ "revalidate_run",
78
+ "stale",
79
+ "state",
80
+ ]);
81
+
82
+ function typeOf(value: unknown): string | null {
83
+ if (typeof value !== "object" || value === null) {
84
+ return null;
85
+ }
86
+ const type = (value as { type?: unknown }).type;
87
+ return typeof type === "string" ? type : null;
88
+ }
89
+
90
+ /** Narrows a port message to the tab-to-worker vocabulary. */
91
+ export function isTabMessage(value: unknown): value is TabToWorker {
92
+ const type = typeOf(value);
93
+ return type !== null && TAB_TYPES.has(type);
94
+ }
95
+
96
+ /** Narrows a port message to the worker-to-tab vocabulary. */
97
+ export function isWorkerMessage(value: unknown): value is WorkerToTab {
98
+ const type = typeOf(value);
99
+ return type !== null && WORKER_TYPES.has(type);
100
+ }
package/src/reducer.ts ADDED
@@ -0,0 +1,548 @@
1
+ import type { LifecycleEvent, RevalidateCause } from "./lifecycle.js";
2
+ import { type TimingConfig, watchdogMs } from "./timing.js";
3
+ import type { Hello } from "./wire.js";
4
+
5
+ /**
6
+ * The connection lifecycle. `generation` is monotonic for the runtime's lifetime; a state
7
+ * reached by aborting carries a generation the aborted attempt does not have.
8
+ */
9
+ export type ClientState =
10
+ | { kind: "stopped"; generation: number }
11
+ | { kind: "connecting"; generation: number; attempt: number; visible: boolean; online: boolean }
12
+ | {
13
+ kind: "open";
14
+ generation: number;
15
+ attempt: number;
16
+ openedAt: number;
17
+ lastByteAt: number;
18
+ /** The hello's keepalive_ms; the watchdog interval derives from it on every re-arm. */
19
+ keepaliveMs: number;
20
+ visible: boolean;
21
+ /** Invariant: hiddenAt !== null exactly when visible === false. */
22
+ hiddenAt: number | null;
23
+ online: boolean;
24
+ }
25
+ | {
26
+ kind: "backoff";
27
+ generation: number;
28
+ attempt: number;
29
+ until: number;
30
+ visible: boolean;
31
+ online: boolean;
32
+ }
33
+ | { kind: "offline"; generation: number; attempt: number; visible: boolean }
34
+ | { kind: "hidden_closed"; generation: number; attempt: number; online: boolean };
35
+
36
+ export type StreamEnd =
37
+ | "eof"
38
+ | "error"
39
+ | "reset:slow"
40
+ | "reset:shutdown"
41
+ | "watchdog"
42
+ | "frame_too_large"
43
+ | "hold_timeout"
44
+ | "hold_overflow"
45
+ | "revalidate_failed";
46
+
47
+ export type ConnectFailure =
48
+ | { kind: "timeout_headers" }
49
+ | { kind: "timeout_hello" }
50
+ | { kind: "network" }
51
+ | { kind: "status"; status: number }
52
+ | { kind: "content_type"; value: string }
53
+ | { kind: "bad_hello" }
54
+ | { kind: "wire_unsupported"; wire: number | null };
55
+
56
+ /** Every event a connection attempt produces carries the generation of that attempt. */
57
+ interface FromTransport {
58
+ readonly generation: number;
59
+ }
60
+
61
+ export type ClientEvent =
62
+ | { type: "start"; now: number; visible: boolean; online: boolean }
63
+ | { type: "stop" }
64
+ | { type: "reconnect"; now: number }
65
+ | ({ type: "connected"; now: number; hello: Hello } & FromTransport)
66
+ | ({ type: "connect_failed"; now: number; reason: ConnectFailure; rand: number } & FromTransport)
67
+ | ({ type: "byte"; now: number } & FromTransport)
68
+ | ({
69
+ type: "stream_ended";
70
+ now: number;
71
+ reason: StreamEnd;
72
+ rand: number;
73
+ retryMs: number;
74
+ } & FromTransport)
75
+ | { type: "watchdog_fired"; now: number; rand: number; retryMs: number }
76
+ | { type: "hidden_timer_fired"; now: number }
77
+ | { type: "backoff_elapsed"; now: number }
78
+ | { type: "stable_elapsed"; now: number }
79
+ | { type: "visible"; now: number; rand: number; retryMs: number }
80
+ | { type: "hidden"; now: number }
81
+ | { type: "online"; now: number }
82
+ | { type: "offline"; now: number }
83
+ | { type: "pagehide"; now: number }
84
+ | { type: "pageshow"; now: number; rand: number; retryMs: number };
85
+
86
+ export type Timer = "watchdog" | "hidden" | "backoff" | "stable";
87
+
88
+ /**
89
+ * What the runtime executes after a transition. `connect` carries the generation the new
90
+ * attempt is tagged with; `abort` the generation being aborted. The runtime presents its own
91
+ * cursor on `connect`.
92
+ */
93
+ export type Effect =
94
+ | { kind: "connect"; generation: number }
95
+ | { kind: "abort"; generation: number; reason: string }
96
+ | { kind: "arm"; timer: Timer; ms: number }
97
+ | { kind: "disarm"; timer: Timer }
98
+ | { kind: "revalidate"; cause: RevalidateCause }
99
+ | { kind: "emit"; event: LifecycleEvent };
100
+
101
+ export interface Reduction {
102
+ readonly state: ClientState;
103
+ readonly effects: Effect[];
104
+ }
105
+
106
+ const TIMERS: readonly Timer[] = ["watchdog", "hidden", "backoff", "stable"];
107
+
108
+ function transportGeneration(event: ClientEvent): number | null {
109
+ switch (event.type) {
110
+ case "connected":
111
+ case "connect_failed":
112
+ case "byte":
113
+ case "stream_ended":
114
+ return event.generation;
115
+ default:
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /** The state before the first start. */
121
+ export function initialState(): ClientState {
122
+ return { kind: "stopped", generation: 0 };
123
+ }
124
+
125
+ /** Full-jitter backoff delay for `attempt`; `rand` is drawn in [0, 1) by the runtime. */
126
+ export function jitter(attempt: number, rand: number, cfg: TimingConfig): number {
127
+ return rand * Math.min(cfg.capMs, cfg.baseMs * 2 ** attempt);
128
+ }
129
+
130
+ function same(state: ClientState): Reduction {
131
+ return { state, effects: [] };
132
+ }
133
+
134
+ function arm(timer: Timer, ms: number): Effect {
135
+ return { kind: "arm", timer, ms };
136
+ }
137
+
138
+ function disarm(timer: Timer): Effect {
139
+ return { kind: "disarm", timer };
140
+ }
141
+
142
+ function disarmAll(): Effect[] {
143
+ return TIMERS.map(disarm);
144
+ }
145
+
146
+ function abort(generation: number, reason: string): Effect {
147
+ return { kind: "abort", generation, reason };
148
+ }
149
+
150
+ function connect(generation: number): Effect {
151
+ return { kind: "connect", generation };
152
+ }
153
+
154
+ function revalidate(cause: RevalidateCause): Effect {
155
+ return { kind: "revalidate", cause };
156
+ }
157
+
158
+ function emit(event: LifecycleEvent): Effect {
159
+ return { kind: "emit", event };
160
+ }
161
+
162
+ type OpenState = Extract<ClientState, { kind: "open" }>;
163
+ type ConnectingState = Extract<ClientState, { kind: "connecting" }>;
164
+ type BackoffState = Extract<ClientState, { kind: "backoff" }>;
165
+ type OfflineState = Extract<ClientState, { kind: "offline" }>;
166
+ type HiddenClosedState = Extract<ClientState, { kind: "hidden_closed" }>;
167
+
168
+ function connectingState(generation: number, attempt: number, online: boolean): ConnectingState {
169
+ return { kind: "connecting", generation, attempt, visible: true, online };
170
+ }
171
+
172
+ function hiddenClosedState(
173
+ generation: number,
174
+ attempt: number,
175
+ online: boolean,
176
+ ): HiddenClosedState {
177
+ return { kind: "hidden_closed", generation, attempt, online };
178
+ }
179
+
180
+ function offlineState(generation: number, attempt: number, visible: boolean): OfflineState {
181
+ return { kind: "offline", generation, attempt, visible };
182
+ }
183
+
184
+ function endStream(
185
+ state: OpenState,
186
+ now: number,
187
+ reason: StreamEnd,
188
+ rand: number,
189
+ retryMs: number,
190
+ cfg: TimingConfig,
191
+ alwaysAbort: boolean,
192
+ lead: Effect[],
193
+ ): Reduction {
194
+ const g = state.generation;
195
+ const effects: Effect[] = [...lead, disarm("watchdog"), disarm("stable"), disarm("hidden")];
196
+ if (!state.visible) {
197
+ effects.push(abort(g, reason));
198
+ return {
199
+ state: hiddenClosedState(g + 1, state.attempt, state.online),
200
+ effects,
201
+ };
202
+ }
203
+ if (!state.online) {
204
+ effects.push(abort(g, reason));
205
+ return {
206
+ state: offlineState(g + 1, state.attempt, true),
207
+ effects,
208
+ };
209
+ }
210
+ if (now - state.openedAt < cfg.stableMs || reason === "reset:shutdown") {
211
+ const delay = Math.max(jitter(state.attempt, rand, cfg), retryMs);
212
+ effects.push(abort(g, reason), arm("backoff", delay));
213
+ return {
214
+ state: {
215
+ kind: "backoff",
216
+ generation: g + 1,
217
+ attempt: state.attempt + 1,
218
+ until: now + delay,
219
+ visible: true,
220
+ online: true,
221
+ },
222
+ effects,
223
+ };
224
+ }
225
+ if (alwaysAbort) {
226
+ effects.push(abort(g, reason));
227
+ }
228
+ effects.push(connect(g + 1));
229
+ return { state: connectingState(g + 1, state.attempt, true), effects };
230
+ }
231
+
232
+ function reduceStopped(state: ClientState, event: ClientEvent): Reduction {
233
+ if (event.type !== "start") {
234
+ return same(state);
235
+ }
236
+ const g = state.generation;
237
+ if (!event.visible) {
238
+ return same(hiddenClosedState(g, 0, event.online));
239
+ }
240
+ if (!event.online) {
241
+ return same(offlineState(g, 0, true));
242
+ }
243
+ return { state: connectingState(g + 1, 0, true), effects: [connect(g + 1)] };
244
+ }
245
+
246
+ function failConnect(
247
+ state: ConnectingState,
248
+ now: number,
249
+ reason: ConnectFailure,
250
+ rand: number,
251
+ cfg: TimingConfig,
252
+ ): Reduction {
253
+ const effects: Effect[] = [];
254
+ if (reason.kind === "wire_unsupported") {
255
+ effects.push(emit({ kind: "wire_unsupported", wire: reason.wire }));
256
+ }
257
+ if (!state.online) {
258
+ return {
259
+ state: offlineState(state.generation, state.attempt, true),
260
+ effects,
261
+ };
262
+ }
263
+ const delay = jitter(state.attempt, rand, cfg);
264
+ effects.push(arm("backoff", delay));
265
+ return {
266
+ state: {
267
+ kind: "backoff",
268
+ generation: state.generation,
269
+ attempt: state.attempt + 1,
270
+ until: now + delay,
271
+ visible: true,
272
+ online: true,
273
+ },
274
+ effects,
275
+ };
276
+ }
277
+
278
+ function reduceConnecting(
279
+ state: ConnectingState,
280
+ event: ClientEvent,
281
+ cfg: TimingConfig,
282
+ ): Reduction {
283
+ const g = state.generation;
284
+ switch (event.type) {
285
+ case "connected": {
286
+ const hello = event.hello;
287
+ const effects: Effect[] = [
288
+ arm("watchdog", watchdogMs(hello.keepalive_ms, cfg)),
289
+ arm("stable", cfg.stableMs),
290
+ emit({
291
+ kind: "hello",
292
+ verdict: hello.verdict,
293
+ wire: hello.wire,
294
+ resumed: hello.resumed,
295
+ epoch: hello.epoch,
296
+ floor: hello.floor,
297
+ head: hello.head,
298
+ }),
299
+ ];
300
+ if (!hello.resumed) {
301
+ effects.push(revalidate("hello"));
302
+ }
303
+ return {
304
+ state: {
305
+ kind: "open",
306
+ generation: g,
307
+ attempt: state.attempt,
308
+ openedAt: event.now,
309
+ lastByteAt: event.now,
310
+ keepaliveMs: hello.keepalive_ms,
311
+ visible: true,
312
+ hiddenAt: null,
313
+ online: state.online,
314
+ },
315
+ effects,
316
+ };
317
+ }
318
+ case "connect_failed":
319
+ return failConnect(state, event.now, event.reason, event.rand, cfg);
320
+ case "stream_ended":
321
+ return failConnect(state, event.now, { kind: "network" }, event.rand, cfg);
322
+ case "hidden":
323
+ case "pagehide":
324
+ return {
325
+ state: hiddenClosedState(g + 1, state.attempt, state.online),
326
+ effects: [abort(g, event.type)],
327
+ };
328
+ case "offline":
329
+ return {
330
+ state: offlineState(g + 1, state.attempt, true),
331
+ effects: [abort(g, event.type)],
332
+ };
333
+ default:
334
+ return same(state);
335
+ }
336
+ }
337
+
338
+ function wakeOpen(
339
+ state: OpenState,
340
+ now: number,
341
+ cause: RevalidateCause,
342
+ rand: number,
343
+ retryMs: number,
344
+ cfg: TimingConfig,
345
+ ): Reduction {
346
+ const shown: OpenState = { ...state, visible: true, hiddenAt: null };
347
+ const lead: Effect[] = [disarm("hidden"), revalidate(cause)];
348
+ const silence = now - state.lastByteAt;
349
+ if (silence > watchdogMs(state.keepaliveMs, cfg)) {
350
+ lead.push(emit({ kind: "watchdog", sinceLastByteMs: silence }));
351
+ return endStream(shown, now, "watchdog", rand, retryMs, cfg, true, lead);
352
+ }
353
+ lead.push(arm("watchdog", watchdogMs(state.keepaliveMs, cfg)));
354
+ return { state: shown, effects: lead };
355
+ }
356
+
357
+ function reduceOpen(state: OpenState, event: ClientEvent, cfg: TimingConfig): Reduction {
358
+ const g = state.generation;
359
+ switch (event.type) {
360
+ case "byte": {
361
+ const next: OpenState = { ...state, lastByteAt: event.now };
362
+ const effects = state.visible ? [arm("watchdog", watchdogMs(state.keepaliveMs, cfg))] : [];
363
+ return { state: next, effects };
364
+ }
365
+ case "watchdog_fired": {
366
+ if (!state.visible) {
367
+ return same(state);
368
+ }
369
+ const lead = [emit({ kind: "watchdog", sinceLastByteMs: event.now - state.lastByteAt })];
370
+ return endStream(state, event.now, "watchdog", event.rand, event.retryMs, cfg, true, lead);
371
+ }
372
+ case "stream_ended":
373
+ return endStream(state, event.now, event.reason, event.rand, event.retryMs, cfg, false, []);
374
+ case "stable_elapsed":
375
+ return same({ ...state, attempt: 0 });
376
+ case "hidden":
377
+ if (!state.visible) {
378
+ return same(state);
379
+ }
380
+ return {
381
+ state: { ...state, visible: false, hiddenAt: event.now },
382
+ effects: [disarm("watchdog"), arm("hidden", cfg.hiddenCloseAfterMs)],
383
+ };
384
+ case "hidden_timer_fired":
385
+ if (state.visible) {
386
+ return same(state);
387
+ }
388
+ return {
389
+ state: hiddenClosedState(g + 1, state.attempt, state.online),
390
+ effects: [abort(g, "hidden_timer"), disarm("stable"), disarm("hidden")],
391
+ };
392
+ case "visible":
393
+ if (state.visible) {
394
+ return same(state);
395
+ }
396
+ return wakeOpen(state, event.now, "visible", event.rand, event.retryMs, cfg);
397
+ case "pageshow":
398
+ if (state.visible) {
399
+ return { state, effects: [revalidate("pageshow")] };
400
+ }
401
+ return wakeOpen(state, event.now, "pageshow", event.rand, event.retryMs, cfg);
402
+ case "pagehide":
403
+ return {
404
+ state: hiddenClosedState(g + 1, state.attempt, state.online),
405
+ effects: [abort(g, "pagehide"), ...disarmAll()],
406
+ };
407
+ case "offline":
408
+ return {
409
+ state: offlineState(g + 1, state.attempt, state.visible),
410
+ effects: [abort(g, "offline"), ...disarmAll()],
411
+ };
412
+ default:
413
+ return same(state);
414
+ }
415
+ }
416
+
417
+ function reduceBackoff(state: BackoffState, event: ClientEvent): Reduction {
418
+ const g = state.generation;
419
+ switch (event.type) {
420
+ case "backoff_elapsed":
421
+ return {
422
+ state: connectingState(g + 1, state.attempt, state.online),
423
+ effects: [connect(g + 1)],
424
+ };
425
+ case "visible":
426
+ case "pageshow":
427
+ case "online":
428
+ return {
429
+ state: connectingState(g + 1, state.attempt, true),
430
+ effects: [disarm("backoff"), connect(g + 1), revalidate(event.type)],
431
+ };
432
+ case "hidden":
433
+ case "pagehide":
434
+ return {
435
+ state: hiddenClosedState(g, state.attempt, state.online),
436
+ effects: [disarm("backoff")],
437
+ };
438
+ case "offline":
439
+ return {
440
+ state: offlineState(g, state.attempt, state.visible),
441
+ effects: [disarm("backoff")],
442
+ };
443
+ default:
444
+ return same(state);
445
+ }
446
+ }
447
+
448
+ function reduceHiddenClosed(state: HiddenClosedState, event: ClientEvent): Reduction {
449
+ const g = state.generation;
450
+ switch (event.type) {
451
+ case "visible":
452
+ case "pageshow":
453
+ if (!state.online) {
454
+ return {
455
+ state: offlineState(g, state.attempt, true),
456
+ effects: [revalidate(event.type)],
457
+ };
458
+ }
459
+ return {
460
+ state: connectingState(g + 1, state.attempt, true),
461
+ effects: [connect(g + 1), revalidate(event.type)],
462
+ };
463
+ case "online":
464
+ return same({ ...state, online: true });
465
+ case "offline":
466
+ return same(offlineState(g, state.attempt, false));
467
+ default:
468
+ return same(state);
469
+ }
470
+ }
471
+
472
+ function reduceOffline(state: OfflineState, event: ClientEvent): Reduction {
473
+ const g = state.generation;
474
+ switch (event.type) {
475
+ case "online":
476
+ if (!state.visible) {
477
+ return same(hiddenClosedState(g, state.attempt, true));
478
+ }
479
+ return {
480
+ state: connectingState(g + 1, state.attempt, true),
481
+ effects: [connect(g + 1), revalidate("online")],
482
+ };
483
+ case "visible":
484
+ case "pageshow":
485
+ return { state: { ...state, visible: true }, effects: [revalidate(event.type)] };
486
+ case "hidden":
487
+ case "pagehide":
488
+ return same({ ...state, visible: false });
489
+ default:
490
+ return same(state);
491
+ }
492
+ }
493
+
494
+ /** Total, pure transition function: no clock, no randomness, no DOM. */
495
+ export function reduce(state: ClientState, event: ClientEvent, cfg: TimingConfig): Reduction {
496
+ if (state.kind === "stopped") {
497
+ return reduceStopped(state, event);
498
+ }
499
+ if (event.type === "start") {
500
+ return same(state);
501
+ }
502
+ const g = state.generation;
503
+ if (event.type === "stop") {
504
+ return {
505
+ state: { kind: "stopped", generation: g + 1 },
506
+ effects: [abort(g, "stop"), ...disarmAll()],
507
+ };
508
+ }
509
+ if (event.type === "reconnect") {
510
+ if (state.kind === "open" || state.kind === "connecting") {
511
+ return {
512
+ state: connectingState(g + 1, state.attempt, state.online),
513
+ effects: [abort(g, "reconnect"), connect(g + 1)],
514
+ };
515
+ }
516
+ if (state.kind === "backoff") {
517
+ return {
518
+ state: connectingState(g + 1, state.attempt, state.online),
519
+ effects: [disarm("backoff"), connect(g + 1)],
520
+ };
521
+ }
522
+ return same(state);
523
+ }
524
+ const eventGeneration = transportGeneration(event);
525
+ if (eventGeneration !== null) {
526
+ if (state.kind !== "connecting" && state.kind !== "open") {
527
+ return same(state);
528
+ }
529
+ if (eventGeneration !== g) {
530
+ return same(state);
531
+ }
532
+ if (state.kind === "open" && (event.type === "connected" || event.type === "connect_failed")) {
533
+ return same(state);
534
+ }
535
+ }
536
+ switch (state.kind) {
537
+ case "connecting":
538
+ return reduceConnecting(state, event, cfg);
539
+ case "open":
540
+ return reduceOpen(state, event, cfg);
541
+ case "backoff":
542
+ return reduceBackoff(state, event);
543
+ case "hidden_closed":
544
+ return reduceHiddenClosed(state, event);
545
+ case "offline":
546
+ return reduceOffline(state, event);
547
+ }
548
+ }