@steve31415/baselib 2.0.0 → 2.2.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,832 @@
1
+ const RECONNECT_DELAYS_SECONDS = [1, 2, 4, 8, 16, 30];
2
+ const PING_INTERVAL_MS = 30_000;
3
+ const CONNECT_WATCHDOG_MS = 10_000;
4
+ const DEFAULT_RECONNECT_BEFORE_MS = 55 * 60 * 1000;
5
+ const FAILURE_TIMEOUT_MS = 60_000;
6
+ const OUTAGE_ESCALATE_MS = 30_000;
7
+ const CLOSE_NO_USER = 4403;
8
+ const PROBE_AFTER_ATTEMPTS = 2;
9
+ const SOCKET_OPEN = 1;
10
+ class LifecycleAbort {
11
+ reason;
12
+ constructor(reason) {
13
+ this.reason = reason;
14
+ }
15
+ }
16
+ function isLifecycleAbort(error) {
17
+ return error instanceof LifecycleAbort;
18
+ }
19
+ export class SyncClient {
20
+ options;
21
+ socket = null;
22
+ pending = new Map();
23
+ unresolvedFailures = new Set();
24
+ rejected = new Map();
25
+ confirmedPending = new Map();
26
+ pendingRejections = new Map();
27
+ settlementInFlight = new Set();
28
+ settledPending = new Set();
29
+ failureTimers = new Map();
30
+ subscribers = new Set();
31
+ abortControllers = new Set();
32
+ removeListeners = [];
33
+ currentState = { kind: 'starting', pending: 0 };
34
+ reconnectAttempts = 0;
35
+ reconnectTimer = null;
36
+ countdownTimer = null;
37
+ pingTimer = null;
38
+ watchdogTimer = null;
39
+ rolloverTimer = null;
40
+ escalateTimer = null;
41
+ escalateArmedAt = 0;
42
+ outageStartedAt = null;
43
+ outageEscalated = false;
44
+ halted = false;
45
+ refetchInFlight = false;
46
+ probeInFlight = false;
47
+ resyncTimes = [];
48
+ reconciliationTail = Promise.resolve();
49
+ initialization = null;
50
+ disposed = false;
51
+ constructor(options) {
52
+ this.options = options;
53
+ }
54
+ init() {
55
+ if (this.disposed)
56
+ return Promise.resolve();
57
+ if (this.initialization === null) {
58
+ let resolve;
59
+ let reject;
60
+ this.initialization = new Promise((res, rej) => {
61
+ resolve = res;
62
+ reject = rej;
63
+ });
64
+ void this.initialize().then(resolve, reject);
65
+ }
66
+ return this.initialization;
67
+ }
68
+ async initialize() {
69
+ this.publish({ kind: 'starting', pending: this.pendingCount() });
70
+ this.removeListeners.push(this.options.authState.onBlocked(() => this.halt()));
71
+ if (this.stopBeforeStoreMutation())
72
+ return;
73
+ this.options.markPerformance?.('outbox-start');
74
+ const outboxPromise = this.options.outbox
75
+ .init()
76
+ .then((entries) => {
77
+ this.options.markPerformance?.('outbox-ready');
78
+ return entries;
79
+ })
80
+ .catch((error) => {
81
+ this.options.logger.error('outbox initialization failed', { error });
82
+ throw error;
83
+ });
84
+ this.options.markPerformance?.('bootstrap-start');
85
+ const bootstrapPromise = this.abortable((signal) => this.options.fetchBootstrap(signal))
86
+ .then((bootstrap) => {
87
+ this.options.markPerformance?.('bootstrap-response');
88
+ return bootstrap;
89
+ })
90
+ .catch((error) => {
91
+ if (!isLifecycleAbort(error))
92
+ this.options.logger.error('bootstrap failed', { error });
93
+ throw error;
94
+ });
95
+ let recovered;
96
+ let bootstrap;
97
+ try {
98
+ const startup = await Promise.all([outboxPromise, bootstrapPromise]);
99
+ recovered = startup[0];
100
+ bootstrap = startup[1];
101
+ }
102
+ catch (error) {
103
+ this.abortFetches();
104
+ throw isLifecycleAbort(error) ? error.reason : error;
105
+ }
106
+ if (this.stopBeforeStoreMutation())
107
+ return;
108
+ this.options.store.installBootstrap(bootstrap);
109
+ for (const persisted of recovered) {
110
+ if (this.stopBeforeStoreMutation())
111
+ return;
112
+ const entry = {
113
+ event: persisted.event,
114
+ summary: persisted.summary,
115
+ createdAt: persisted.createdAt,
116
+ ordinal: persisted.ordinal,
117
+ status: 'pending',
118
+ recovered: true,
119
+ };
120
+ this.pending.set(entry.event.eventGuid, entry);
121
+ this.options.store.restorePending(entry.event);
122
+ }
123
+ this.options.markPerformance?.('store-ready');
124
+ if (this.stopBeforeStoreMutation())
125
+ return;
126
+ this.installRuntimeListeners();
127
+ this.connect();
128
+ }
129
+ dispose() {
130
+ if (this.disposed)
131
+ return;
132
+ this.disposed = true;
133
+ this.abortFetches();
134
+ for (const remove of this.removeListeners.splice(0))
135
+ remove();
136
+ this.clearAllTimers();
137
+ const socket = this.socket;
138
+ this.socket = null;
139
+ if (socket) {
140
+ socket.close();
141
+ this.detachSocket(socket);
142
+ }
143
+ this.subscribers.clear();
144
+ }
145
+ async submit(event, summary) {
146
+ if (this.disposed)
147
+ return;
148
+ const persisted = {
149
+ event,
150
+ summary,
151
+ createdAt: this.options.runtime.now(),
152
+ };
153
+ const initialization = this.init();
154
+ if (this.stopBeforeStoreMutation()) {
155
+ await initialization;
156
+ return;
157
+ }
158
+ await this.options.outbox.add(persisted);
159
+ await initialization;
160
+ if (this.stopBeforeStoreMutation())
161
+ return;
162
+ const entry = { ...persisted, status: 'pending' };
163
+ this.pending.set(event.eventGuid, entry);
164
+ this.options.store.applyOptimistic(event);
165
+ this.failureTimers.set(event.eventGuid, this.options.runtime.setTimeout(() => this.failUnconfirmed(event.eventGuid), FAILURE_TIMEOUT_MS));
166
+ this.trySend(entry);
167
+ this.updatePendingState();
168
+ this.options.onSafetyChange?.();
169
+ }
170
+ /** Current-tab entries only; recovered work is retried silently. */
171
+ pendingEntries() {
172
+ return [...this.pending.values()].filter((entry) => !entry.recovered);
173
+ }
174
+ failedEntries() {
175
+ return [...this.rejected.values()];
176
+ }
177
+ hasPendingWork() {
178
+ return this.pendingEntries().length > 0;
179
+ }
180
+ hasUnsafeWork() {
181
+ return this.hasPendingWork() || this.hasUnresolvedFailures();
182
+ }
183
+ hasUnresolvedFailures() {
184
+ return this.unresolvedFailures.size > 0;
185
+ }
186
+ dismissFailure(eventGuid) {
187
+ if (!this.rejected.delete(eventGuid))
188
+ return;
189
+ this.unresolvedFailures.delete(eventGuid);
190
+ this.options.onSafetyChange?.();
191
+ }
192
+ refreshConnectionForBuild() {
193
+ if (this.halted || this.disposed)
194
+ return;
195
+ this.options.logger.info('sync reconnect for traffic build');
196
+ const previous = this.socket;
197
+ this.socket = null;
198
+ this.clearConnectionTimers();
199
+ if (previous) {
200
+ previous.close();
201
+ this.detachSocket(previous);
202
+ }
203
+ this.connectNow();
204
+ }
205
+ state() {
206
+ return this.currentState;
207
+ }
208
+ subscribe(listener) {
209
+ this.subscribers.add(listener);
210
+ return () => this.subscribers.delete(listener);
211
+ }
212
+ installRuntimeListeners() {
213
+ this.removeListeners.push(this.options.runtime.onVisibilityChange(() => {
214
+ if (this.options.runtime.visibility() === 'visible')
215
+ this.wakeConnection();
216
+ }), this.options.runtime.onOnline(() => this.wakeConnection()), this.options.runtime.onPageHide(() => this.flushViaBeacon()));
217
+ }
218
+ wakeConnection() {
219
+ if (this.halted || this.disposed)
220
+ return;
221
+ if (this.socket !== null) {
222
+ if (this.socket.readyState === SOCKET_OPEN)
223
+ this.sendConnect();
224
+ return;
225
+ }
226
+ this.connectNow();
227
+ }
228
+ connect() {
229
+ if (this.halted || this.disposed)
230
+ return;
231
+ this.publish(this.reconnectAttempts > 0
232
+ ? { kind: 'disconnected', pending: this.pendingCount() }
233
+ : { kind: 'connecting', pending: this.pendingCount() });
234
+ let socket;
235
+ try {
236
+ socket = this.options.runtime.createSocket(this.options.socketUrl());
237
+ }
238
+ catch (error) {
239
+ this.options.logger.warn('socket creation failed', { error });
240
+ this.startOutage(1006);
241
+ this.scheduleReconnect();
242
+ return;
243
+ }
244
+ this.socket = socket;
245
+ this.watchdogTimer = this.options.runtime.setTimeout(() => {
246
+ if (socket.readyState !== SOCKET_OPEN)
247
+ socket.close();
248
+ }, CONNECT_WATCHDOG_MS);
249
+ socket.onopen = () => {
250
+ if (this.socket !== socket) {
251
+ socket.close();
252
+ return;
253
+ }
254
+ if (this.watchdogTimer !== null)
255
+ this.options.runtime.clearTimeout(this.watchdogTimer);
256
+ this.watchdogTimer = null;
257
+ this.sendConnect();
258
+ this.pingTimer = this.options.runtime.setInterval(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
259
+ this.rolloverTimer = this.options.runtime.setTimeout(() => {
260
+ this.options.logger.info('ws rollover', {});
261
+ socket.close();
262
+ }, this.options.reconnectBeforeMs ?? DEFAULT_RECONNECT_BEFORE_MS);
263
+ };
264
+ socket.onmessage = (event) => {
265
+ if (this.socket === socket)
266
+ this.handleMessage(String(event.data));
267
+ };
268
+ socket.onerror = () => {
269
+ // Browsers expose no useful detail; the close event owns recovery.
270
+ };
271
+ socket.onclose = (event) => {
272
+ if (this.socket !== socket)
273
+ return;
274
+ this.clearConnectionTimers();
275
+ this.socket = null;
276
+ if (this.halted || this.disposed)
277
+ return;
278
+ if (event.code === CLOSE_NO_USER) {
279
+ this.options.authState.markBlocked();
280
+ this.halt();
281
+ return;
282
+ }
283
+ this.startOutage(event.code);
284
+ this.scheduleReconnect();
285
+ };
286
+ }
287
+ connectNow() {
288
+ if (this.reconnectTimer !== null)
289
+ this.options.runtime.clearTimeout(this.reconnectTimer);
290
+ if (this.countdownTimer !== null)
291
+ this.options.runtime.clearInterval(this.countdownTimer);
292
+ this.reconnectTimer = null;
293
+ this.countdownTimer = null;
294
+ this.connect();
295
+ }
296
+ scheduleReconnect() {
297
+ const delay = RECONNECT_DELAYS_SECONDS[Math.min(this.reconnectAttempts, RECONNECT_DELAYS_SECONDS.length - 1)];
298
+ this.reconnectAttempts++;
299
+ if (this.reconnectAttempts >= PROBE_AFTER_ATTEMPTS)
300
+ void this.probeSession();
301
+ let remaining = delay;
302
+ this.publish({ kind: 'reconnecting', pending: this.pendingCount(), inSeconds: remaining });
303
+ this.countdownTimer = this.options.runtime.setInterval(() => {
304
+ remaining--;
305
+ if (remaining > 0) {
306
+ this.publish({ kind: 'reconnecting', pending: this.pendingCount(), inSeconds: remaining });
307
+ }
308
+ }, 1000);
309
+ this.reconnectTimer = this.options.runtime.setTimeout(() => {
310
+ if (this.countdownTimer !== null)
311
+ this.options.runtime.clearInterval(this.countdownTimer);
312
+ this.countdownTimer = null;
313
+ this.reconnectTimer = null;
314
+ this.connect();
315
+ }, delay * 1000);
316
+ }
317
+ async probeSession() {
318
+ if (this.halted || this.disposed || this.probeInFlight)
319
+ return;
320
+ this.probeInFlight = true;
321
+ try {
322
+ const result = await this.abortable((signal) => this.options.fetchCatchup(this.options.store.currentSeq(), signal));
323
+ if (this.stopBeforeStoreMutation())
324
+ return;
325
+ if (result.buildId)
326
+ this.options.onBuildObserved?.(result.buildId);
327
+ if (result.reload) {
328
+ this.requestReload('server');
329
+ }
330
+ else {
331
+ for (const event of result.events)
332
+ this.handleServerEvent(event);
333
+ }
334
+ }
335
+ catch {
336
+ // Offline or aborted: neither says that the session is expired.
337
+ }
338
+ finally {
339
+ this.probeInFlight = false;
340
+ }
341
+ }
342
+ startOutage(closeCode) {
343
+ if (this.outageStartedAt !== null)
344
+ return;
345
+ this.outageStartedAt = this.options.runtime.now();
346
+ this.outageEscalated = false;
347
+ this.escalateArmedAt = this.options.runtime.now();
348
+ this.escalateTimer = this.options.runtime.setTimeout(() => {
349
+ const late = this.options.runtime.now() - this.escalateArmedAt - OUTAGE_ESCALATE_MS;
350
+ if (this.options.runtime.visibility() === 'hidden' || late > 5000)
351
+ return;
352
+ if (this.outageStartedAt !== null) {
353
+ this.outageEscalated = true;
354
+ this.options.logger.error('sync outage ongoing', {
355
+ durationMs: this.options.runtime.now() - this.outageStartedAt,
356
+ closeCode,
357
+ attempts: this.reconnectAttempts,
358
+ });
359
+ }
360
+ }, OUTAGE_ESCALATE_MS);
361
+ }
362
+ handleMessage(raw) {
363
+ let message;
364
+ try {
365
+ message = JSON.parse(raw);
366
+ }
367
+ catch {
368
+ this.options.logger.warn('unparseable ws message', { raw: raw.slice(0, 200) });
369
+ return;
370
+ }
371
+ switch (message.type) {
372
+ case 'build_changed':
373
+ this.options.onBuildChanged?.();
374
+ return;
375
+ case 'sync_available':
376
+ this.sendConnect();
377
+ return;
378
+ case 'pong':
379
+ if (message.buildId)
380
+ this.options.onBuildObserved?.(message.buildId);
381
+ return;
382
+ case 'catchup':
383
+ this.handleSocketCatchup(message);
384
+ return;
385
+ case 'reload':
386
+ this.requestReload('server');
387
+ return;
388
+ case 'event':
389
+ this.handleServerEvent(message.event);
390
+ return;
391
+ case 'rejection':
392
+ this.handleRejection(message);
393
+ return;
394
+ }
395
+ }
396
+ handleSocketCatchup(message) {
397
+ if (message.buildId)
398
+ this.options.onBuildObserved?.(message.buildId);
399
+ let complete = true;
400
+ for (const event of message.events) {
401
+ if (!this.handleServerEvent(event))
402
+ complete = false;
403
+ }
404
+ if (complete && message.currentSeq > this.options.store.currentSeq()) {
405
+ this.requestReload('sequence-gap');
406
+ complete = false;
407
+ }
408
+ if (complete)
409
+ this.onSyncEstablished();
410
+ }
411
+ /** Returns false when a forward gap prevented application. */
412
+ handleServerEvent(event) {
413
+ if (this.stopBeforeStoreMutation())
414
+ return false;
415
+ const currentSeq = this.options.store.currentSeq();
416
+ if (event.seq <= currentSeq) {
417
+ if (this.pending.has(event.eventGuid))
418
+ this.confirmPending(event.eventGuid, true);
419
+ return true;
420
+ }
421
+ if (event.seq > currentSeq + 1) {
422
+ this.requestReload('sequence-gap');
423
+ return false;
424
+ }
425
+ this.options.store.applyServerEvent(event);
426
+ if (this.stopBeforeStoreMutation())
427
+ return false;
428
+ this.options.store.setCurrentSeq(event.seq);
429
+ if (this.pending.has(event.eventGuid))
430
+ this.confirmPending(event.eventGuid, false);
431
+ return true;
432
+ }
433
+ handleRejection(message) {
434
+ const entry = this.pending.get(message.eventGuid);
435
+ if (!entry)
436
+ return;
437
+ if (this.confirmedPending.has(message.eventGuid) || this.pendingRejections.has(message.eventGuid)) {
438
+ this.retrySettlement(message.eventGuid);
439
+ return;
440
+ }
441
+ if (this.stopBeforeStoreMutation())
442
+ return;
443
+ this.pendingRejections.set(message.eventGuid, message);
444
+ this.settledPending.add(message.eventGuid);
445
+ this.clearFailureTimer(message.eventGuid);
446
+ this.options.store.rejectPending(message.eventGuid, message.error);
447
+ if (!entry.recovered) {
448
+ const rejectedEntry = {
449
+ ...entry,
450
+ status: 'rejected',
451
+ error: message.error.message,
452
+ };
453
+ this.unresolvedFailures.add(message.eventGuid);
454
+ this.rejected.set(message.eventGuid, {
455
+ eventGuid: message.eventGuid,
456
+ entry: rejectedEntry,
457
+ error: message.error.message,
458
+ });
459
+ this.options.logger.warn('event rejected', {
460
+ eventGuid: message.eventGuid,
461
+ code: message.error.code,
462
+ message: message.error.message,
463
+ });
464
+ this.options.onEventFailed(rejectedEntry, message.error.message, true);
465
+ }
466
+ else {
467
+ this.unresolvedFailures.add(message.eventGuid);
468
+ }
469
+ this.options.onSafetyChange?.();
470
+ this.updateConnectedState();
471
+ this.retrySettlement(message.eventGuid);
472
+ }
473
+ confirmPending(eventGuid, reconcile) {
474
+ const entry = this.pending.get(eventGuid);
475
+ if (!entry)
476
+ return;
477
+ if (this.pendingRejections.has(eventGuid)) {
478
+ this.retrySettlement(eventGuid);
479
+ return;
480
+ }
481
+ const wasAlreadySettling = this.confirmedPending.has(eventGuid);
482
+ this.confirmedPending.set(eventGuid, reconcile || this.confirmedPending.get(eventGuid) === true);
483
+ this.settledPending.add(eventGuid);
484
+ this.clearFailureTimer(eventGuid);
485
+ if (entry.recovered && !this.unresolvedFailures.has(eventGuid)) {
486
+ this.unresolvedFailures.add(eventGuid);
487
+ this.options.onSafetyChange?.();
488
+ }
489
+ if (!wasAlreadySettling)
490
+ this.updateConnectedState();
491
+ this.retrySettlement(eventGuid);
492
+ }
493
+ clearFailureTimer(eventGuid) {
494
+ const timer = this.failureTimers.get(eventGuid);
495
+ if (timer !== undefined)
496
+ this.options.runtime.clearTimeout(timer);
497
+ this.failureTimers.delete(eventGuid);
498
+ }
499
+ retrySettlement(eventGuid) {
500
+ if (this.halted || this.disposed || this.settlementInFlight.has(eventGuid))
501
+ return;
502
+ if (this.confirmedPending.has(eventGuid)) {
503
+ void this.finishConfirmation(eventGuid);
504
+ }
505
+ else if (this.pendingRejections.has(eventGuid)) {
506
+ void this.finishRejection(eventGuid);
507
+ }
508
+ }
509
+ async finishConfirmation(eventGuid) {
510
+ const entry = this.pending.get(eventGuid);
511
+ if (!entry)
512
+ return;
513
+ const reportRecovery = !entry.recovered && this.unresolvedFailures.has(eventGuid);
514
+ this.settlementInFlight.add(eventGuid);
515
+ try {
516
+ await this.options.outbox.remove(eventGuid);
517
+ if (this.stopBeforeStoreMutation())
518
+ return;
519
+ if (this.confirmedPending.get(eventGuid) && !(await this.reconcileStore()))
520
+ return;
521
+ if (this.stopBeforeStoreMutation())
522
+ return;
523
+ this.pending.delete(eventGuid);
524
+ this.confirmedPending.delete(eventGuid);
525
+ this.settledPending.delete(eventGuid);
526
+ this.unresolvedFailures.delete(eventGuid);
527
+ this.rejected.delete(eventGuid);
528
+ if (reportRecovery)
529
+ this.options.onEventRecovered?.(eventGuid);
530
+ this.options.onSafetyChange?.();
531
+ this.updateConnectedState();
532
+ }
533
+ catch (error) {
534
+ if (!isLifecycleAbort(error) && !this.halted && !this.disposed) {
535
+ this.options.logger.error('confirmed event cleanup failed', { eventGuid, error });
536
+ }
537
+ }
538
+ finally {
539
+ this.settlementInFlight.delete(eventGuid);
540
+ }
541
+ }
542
+ async finishRejection(eventGuid) {
543
+ const entry = this.pending.get(eventGuid);
544
+ if (!entry)
545
+ return;
546
+ this.settlementInFlight.add(eventGuid);
547
+ try {
548
+ await this.options.outbox.remove(eventGuid);
549
+ if (this.stopBeforeStoreMutation() || !(await this.reconcileStore()))
550
+ return;
551
+ if (this.stopBeforeStoreMutation())
552
+ return;
553
+ this.pending.delete(eventGuid);
554
+ this.pendingRejections.delete(eventGuid);
555
+ this.settledPending.delete(eventGuid);
556
+ if (entry.recovered)
557
+ this.unresolvedFailures.delete(eventGuid);
558
+ this.options.onSafetyChange?.();
559
+ this.updateConnectedState();
560
+ }
561
+ catch (error) {
562
+ if (!isLifecycleAbort(error) && !this.halted && !this.disposed) {
563
+ this.options.logger.error('rejected event cleanup failed', { eventGuid, error });
564
+ }
565
+ }
566
+ finally {
567
+ this.settlementInFlight.delete(eventGuid);
568
+ }
569
+ }
570
+ async reconcileStore() {
571
+ const preceding = this.reconciliationTail;
572
+ let release;
573
+ this.reconciliationTail = new Promise((resolve) => {
574
+ release = resolve;
575
+ });
576
+ await preceding;
577
+ try {
578
+ while (true) {
579
+ if (this.stopBeforeStoreMutation())
580
+ return false;
581
+ const startingSeq = this.options.store.currentSeq();
582
+ const bootstrap = await this.abortable((signal) => this.options.fetchBootstrap(signal));
583
+ if (this.stopBeforeStoreMutation())
584
+ return false;
585
+ if (this.options.store.currentSeq() !== startingSeq)
586
+ continue;
587
+ this.options.store.installBootstrap(bootstrap);
588
+ for (const [eventGuid, entry] of this.pending) {
589
+ if (this.settledPending.has(eventGuid))
590
+ continue;
591
+ if (this.stopBeforeStoreMutation())
592
+ return false;
593
+ this.options.store.restorePending(entry.event);
594
+ }
595
+ return true;
596
+ }
597
+ }
598
+ finally {
599
+ release();
600
+ }
601
+ }
602
+ failUnconfirmed(eventGuid) {
603
+ const entry = this.pending.get(eventGuid);
604
+ if (!entry || entry.recovered)
605
+ return;
606
+ entry.status = 'unconfirmed';
607
+ entry.error = 'not confirmed after 60s';
608
+ this.unresolvedFailures.add(eventGuid);
609
+ this.options.onEventFailed(entry, entry.error, false);
610
+ this.options.onSafetyChange?.();
611
+ }
612
+ onSyncEstablished() {
613
+ if (this.outageStartedAt !== null) {
614
+ const durationMs = this.options.runtime.now() - this.outageStartedAt;
615
+ const attempts = this.reconnectAttempts;
616
+ if (this.outageEscalated) {
617
+ this.options.logger.info('sync outage recovered', { durationMs, attempts });
618
+ }
619
+ else if (attempts >= 2) {
620
+ this.options.logger.warn('sync outage recovered', { durationMs, attempts });
621
+ }
622
+ else if (attempts === 1) {
623
+ this.options.logger.info('ws reconnected first try', { durationMs });
624
+ }
625
+ this.outageStartedAt = null;
626
+ this.outageEscalated = false;
627
+ }
628
+ if (this.escalateTimer !== null)
629
+ this.options.runtime.clearTimeout(this.escalateTimer);
630
+ this.escalateTimer = null;
631
+ this.reconnectAttempts = 0;
632
+ for (const [eventGuid, entry] of this.pending) {
633
+ if (this.settledPending.has(eventGuid)) {
634
+ this.retrySettlement(eventGuid);
635
+ }
636
+ else {
637
+ this.trySend(entry);
638
+ }
639
+ }
640
+ this.updateConnectedState();
641
+ }
642
+ requestReload(reason) {
643
+ this.options.onReloadRequired(reason);
644
+ void this.resyncFromServer(reason === 'server' ? 'server requested reload' : 'sequence gap');
645
+ }
646
+ async resyncFromServer(reason) {
647
+ if (this.refetchInFlight || this.halted || this.disposed)
648
+ return;
649
+ const now = this.options.runtime.now();
650
+ this.resyncTimes = [...this.resyncTimes.filter((time) => now - time < 60_000), now];
651
+ if (this.resyncTimes.length > 3) {
652
+ this.options.logger.error('resync loop detected', { reason });
653
+ this.publish({
654
+ kind: 'error',
655
+ pending: this.pendingCount(),
656
+ message: 'Repeated resyncs require a page reload',
657
+ });
658
+ return;
659
+ }
660
+ this.refetchInFlight = true;
661
+ try {
662
+ this.options.logger.info('resyncing from server', { reason });
663
+ if (!(await this.reconcileStore()))
664
+ return;
665
+ for (const entry of this.pending.values())
666
+ this.trySend(entry);
667
+ }
668
+ catch (error) {
669
+ if (!isLifecycleAbort(error) && !this.disposed && !this.halted) {
670
+ this.options.logger.error('resync failed', { reason, error });
671
+ }
672
+ }
673
+ finally {
674
+ this.refetchInFlight = false;
675
+ }
676
+ }
677
+ sendConnect() {
678
+ this.sendRaw({
679
+ type: 'connect',
680
+ clientGuid: this.options.clientGuid,
681
+ lastSeq: this.options.store.currentSeq(),
682
+ });
683
+ }
684
+ sendRaw(message) {
685
+ if (this.socket?.readyState !== SOCKET_OPEN)
686
+ return;
687
+ try {
688
+ this.socket.send(JSON.stringify(message));
689
+ }
690
+ catch {
691
+ // The close handler owns recovery.
692
+ }
693
+ }
694
+ trySend(entry) {
695
+ if (this.settledPending.has(entry.event.eventGuid))
696
+ return;
697
+ this.sendRaw({ type: 'event', clientGuid: this.options.clientGuid, event: entry.event });
698
+ }
699
+ flushViaBeacon() {
700
+ if (this.disposed || this.halted || this.pending.size === 0)
701
+ return;
702
+ const events = [...this.pending.values()]
703
+ .filter((entry) => !this.settledPending.has(entry.event.eventGuid))
704
+ .map((entry) => entry.event);
705
+ if (events.length === 0)
706
+ return;
707
+ const body = JSON.stringify({
708
+ clientGuid: this.options.clientGuid,
709
+ events,
710
+ });
711
+ this.options.runtime.sendBeacon(this.options.beaconUrl, new Blob([body], { type: 'application/json' }));
712
+ }
713
+ updateConnectedState() {
714
+ if (this.halted || this.disposed || this.socket?.readyState !== SOCKET_OPEN)
715
+ return;
716
+ const pending = this.pendingCount();
717
+ this.publish(pending > 0 ? { kind: 'syncing', pending } : { kind: 'connected', pending: 0 });
718
+ }
719
+ updatePendingState() {
720
+ if (this.halted || this.disposed)
721
+ return;
722
+ if (this.socket?.readyState === SOCKET_OPEN) {
723
+ this.updateConnectedState();
724
+ return;
725
+ }
726
+ const pending = this.pendingCount();
727
+ switch (this.currentState.kind) {
728
+ case 'starting':
729
+ case 'connecting':
730
+ case 'disconnected':
731
+ this.publish({ kind: this.currentState.kind, pending });
732
+ return;
733
+ case 'reconnecting':
734
+ this.publish({ kind: 'reconnecting', pending, inSeconds: this.currentState.inSeconds });
735
+ return;
736
+ case 'error':
737
+ this.publish({ kind: 'error', pending, message: this.currentState.message });
738
+ return;
739
+ case 'expired':
740
+ this.publish({ kind: 'expired', pending });
741
+ return;
742
+ case 'connected':
743
+ case 'syncing':
744
+ return;
745
+ }
746
+ }
747
+ publish(state) {
748
+ if (this.disposed)
749
+ return;
750
+ this.currentState = state;
751
+ this.options.onState(state);
752
+ for (const subscriber of this.subscribers)
753
+ subscriber(state);
754
+ }
755
+ pendingCount() {
756
+ return this.pendingEntries().length;
757
+ }
758
+ stopBeforeStoreMutation() {
759
+ if (this.disposed || this.halted)
760
+ return true;
761
+ if (!this.options.authState.isBlocked())
762
+ return false;
763
+ this.halt();
764
+ return true;
765
+ }
766
+ halt() {
767
+ if (this.halted || this.disposed)
768
+ return;
769
+ this.halted = true;
770
+ this.abortFetches();
771
+ this.clearAllTimers();
772
+ const socket = this.socket;
773
+ this.socket = null;
774
+ if (socket) {
775
+ socket.close();
776
+ this.detachSocket(socket);
777
+ }
778
+ this.publish({ kind: 'expired', pending: this.pendingCount() });
779
+ }
780
+ clearConnectionTimers() {
781
+ if (this.pingTimer !== null)
782
+ this.options.runtime.clearInterval(this.pingTimer);
783
+ if (this.watchdogTimer !== null)
784
+ this.options.runtime.clearTimeout(this.watchdogTimer);
785
+ if (this.rolloverTimer !== null)
786
+ this.options.runtime.clearTimeout(this.rolloverTimer);
787
+ this.pingTimer = null;
788
+ this.watchdogTimer = null;
789
+ this.rolloverTimer = null;
790
+ }
791
+ clearAllTimers() {
792
+ this.clearConnectionTimers();
793
+ if (this.reconnectTimer !== null)
794
+ this.options.runtime.clearTimeout(this.reconnectTimer);
795
+ if (this.countdownTimer !== null)
796
+ this.options.runtime.clearInterval(this.countdownTimer);
797
+ if (this.escalateTimer !== null)
798
+ this.options.runtime.clearTimeout(this.escalateTimer);
799
+ for (const timer of this.failureTimers.values())
800
+ this.options.runtime.clearTimeout(timer);
801
+ this.reconnectTimer = null;
802
+ this.countdownTimer = null;
803
+ this.escalateTimer = null;
804
+ this.failureTimers.clear();
805
+ }
806
+ detachSocket(socket) {
807
+ socket.onopen = null;
808
+ socket.onmessage = null;
809
+ socket.onerror = null;
810
+ socket.onclose = null;
811
+ }
812
+ async abortable(operation) {
813
+ const controller = new AbortController();
814
+ this.abortControllers.add(controller);
815
+ try {
816
+ return await operation(controller.signal);
817
+ }
818
+ catch (error) {
819
+ if (controller.signal.aborted)
820
+ throw new LifecycleAbort(error);
821
+ throw error;
822
+ }
823
+ finally {
824
+ this.abortControllers.delete(controller);
825
+ }
826
+ }
827
+ abortFetches() {
828
+ for (const controller of this.abortControllers)
829
+ controller.abort();
830
+ this.abortControllers.clear();
831
+ }
832
+ }