@syncular/client 0.25.2 → 0.26.1

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/src/multi-tab.ts CHANGED
@@ -11,11 +11,12 @@
11
11
  * survive `postMessage` on a BroadcastChannel):
12
12
  *
13
13
  * follower → leader
14
- * hello {t,epoch?,fromId} — "who's the leader?" on join
14
+ * hello {t,epoch?,fromId} — "who's the leader?" on join,
15
+ * and the follower's liveness probe
15
16
  * req {t,epoch,fromId,reqId,method,args}
16
- * bye {t,fromId} — follower leaving (best effort)
17
17
  * leader → all
18
- * announce {t,epoch,clientId} — "I am the leader, this epoch"
18
+ * announce {t,epoch,clientId} — "I am the leader, this epoch";
19
+ * the answer to every hello
19
20
  * res {t,epoch,reqId,ok,value|error} — reply to one req
20
21
  * event {t,epoch,event} — fan-out (invalidate/presence/…)
21
22
  *
@@ -29,6 +30,16 @@
29
30
  * promoter reads the highest epoch it has seen and adds one, so successive
30
31
  * leaders always strictly increase it even across the lock-handover gap.
31
32
  *
33
+ * Liveness is follower-driven: the leader runs no timer. Browsers throttle
34
+ * the timers of a hidden tab (Chrome's intensive throttling wakes them about
35
+ * once a minute) but still dispatch its BroadcastChannel messages, so a leader
36
+ * heartbeat misses its schedule in a background leader tab while that tab is
37
+ * alive. Instead, a bound follower that has heard no `announce` for a third of
38
+ * its call timeout posts a `hello`, and goes `blocked` only when that probe
39
+ * stays unanswered for the rest of the timeout. A leader tab whose main thread
40
+ * processes no messages (hung or frozen) therefore still blocks its followers
41
+ * within `callTimeoutMs`, and a closed leader hands over through the lock.
42
+ *
32
43
  * Presence identity: all tabs share the leader's one connection, so a device
33
44
  * is exactly ONE presence peer collectively — `(actorId, leaderClientId)`.
34
45
  * A follower's `setPresence` forwards to the leader's single publisher; there
@@ -76,16 +87,6 @@ interface HelloMessage {
76
87
  readonly epoch?: number;
77
88
  }
78
89
 
79
- interface ByeMessage {
80
- readonly t: 'bye';
81
- readonly fromId: string;
82
- }
83
-
84
- interface BoundMessage {
85
- readonly t: 'bound';
86
- readonly fromId: string;
87
- }
88
-
89
90
  interface ReqMessage {
90
91
  readonly t: 'req';
91
92
  readonly epoch: number;
@@ -118,8 +119,6 @@ interface EventMessage {
118
119
 
119
120
  export type MultiTabMessage =
120
121
  | HelloMessage
121
- | ByeMessage
122
- | BoundMessage
123
122
  | ReqMessage
124
123
  | AnnounceMessage
125
124
  | ResMessage
@@ -181,9 +180,6 @@ export class LeaderBridge {
181
180
  args: readonly unknown[],
182
181
  ) => Promise<unknown>;
183
182
  readonly #onMessage: (event: { data: MultiTabMessage }) => void;
184
- readonly #heartbeatMs: number;
185
- readonly #followers = new Set<string>();
186
- #heartbeat: ReturnType<typeof setInterval> | undefined;
187
183
  #closed = false;
188
184
 
189
185
  constructor(options: {
@@ -191,21 +187,17 @@ export class LeaderBridge {
191
187
  epoch: number;
192
188
  clientId: string;
193
189
  invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
194
- heartbeatMs?: number;
195
190
  }) {
196
191
  this.#channel = options.channel;
197
192
  this.#epoch = options.epoch;
198
193
  this.#clientId = options.clientId;
199
194
  this.#invoke = options.invoke;
200
- this.#heartbeatMs =
201
- options.heartbeatMs ??
202
- Math.max(50, Math.floor(DEFAULT_FOLLOWER_CALL_TIMEOUT_MS / 3));
203
195
  this.#onMessage = (event) => this.#handle(event.data);
204
196
  this.#channel.addEventListener('message', this.#onMessage);
205
197
  this.announce();
206
198
  }
207
199
 
208
- /** Re-announce leadership (on promotion and on a follower `hello`). */
200
+ /** Announce leadership (on promotion and in answer to every `hello`). */
209
201
  announce(): void {
210
202
  if (this.#closed) return;
211
203
  this.#channel.postMessage({
@@ -224,23 +216,10 @@ export class LeaderBridge {
224
216
  #handle(message: MultiTabMessage): void {
225
217
  if (this.#closed) return;
226
218
  if (message.t === 'hello') {
227
- // A follower joined (or is contesting) — tell it who leads.
228
- this.#trackFollower(message.fromId);
219
+ // A follower joined or probes liveness: tell it who leads.
229
220
  this.announce();
230
221
  return;
231
222
  }
232
- if (message.t === 'bound') {
233
- this.#trackFollower(message.fromId);
234
- return;
235
- }
236
- if (message.t === 'bye') {
237
- this.#followers.delete(message.fromId);
238
- if (this.#followers.size === 0 && this.#heartbeat !== undefined) {
239
- clearInterval(this.#heartbeat);
240
- this.#heartbeat = undefined;
241
- }
242
- return;
243
- }
244
223
  if (message.t !== 'req') return;
245
224
  // Ignore requests stamped for a different (dead/older) leader; that
246
225
  // follower will re-stamp once it sees our announce.
@@ -281,17 +260,9 @@ export class LeaderBridge {
281
260
  );
282
261
  }
283
262
 
284
- #trackFollower(fromId: string): void {
285
- this.#followers.add(fromId);
286
- if (this.#heartbeat !== undefined) return;
287
- this.#heartbeat = setInterval(() => this.announce(), this.#heartbeatMs);
288
- }
289
-
290
263
  close(): void {
291
264
  if (this.#closed) return;
292
265
  this.#closed = true;
293
- if (this.#heartbeat !== undefined) clearInterval(this.#heartbeat);
294
- this.#heartbeat = undefined;
295
266
  this.#channel.removeEventListener('message', this.#onMessage);
296
267
  this.#channel.close();
297
268
  }
@@ -301,18 +272,24 @@ export class LeaderBridge {
301
272
  // Follower side: proxy the logical API to the leader over the channel
302
273
  // ---------------------------------------------------------------------------
303
274
 
275
+ /**
276
+ * Timer seam for {@link FollowerLink}: run `callback` after `delayMs` and
277
+ * return a function that cancels it. Tests inject a manual clock.
278
+ */
279
+ type FollowerSchedule = (callback: () => void, delayMs: number) => () => void;
280
+
304
281
  interface QueuedCall {
305
282
  readonly method: string;
306
283
  readonly args: readonly unknown[];
307
284
  readonly resolve: (value: unknown) => void;
308
285
  readonly reject: (error: unknown) => void;
309
- timer: ReturnType<typeof setTimeout> | undefined;
286
+ cancelTimer: (() => void) | undefined;
310
287
  }
311
288
 
312
289
  interface InFlight {
313
290
  readonly resolve: (value: unknown) => void;
314
291
  readonly reject: (error: unknown) => void;
315
- timer: ReturnType<typeof setTimeout> | undefined;
292
+ readonly cancelTimer: () => void;
316
293
  }
317
294
 
318
295
  /**
@@ -320,7 +297,9 @@ interface InFlight {
320
297
  * matching `res`; queues calls (bounded, timed) while no leader is bound (the
321
298
  * handover gap) and flushes them once an `announce` binds a new leader.
322
299
  * Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
323
- * the resolved leader `clientId` back through `onLeaderChange`.
300
+ * the resolved leader `clientId` back through `onLeaderChange`. Probes the
301
+ * bound leader with `hello` after a quiet period and reports `blocked` only
302
+ * when a probe goes unanswered (see the module comment).
324
303
  */
325
304
  export class FollowerLink {
326
305
  readonly #channel: CrossTabChannel;
@@ -329,7 +308,10 @@ export class FollowerLink {
329
308
  readonly #onLeaderChange: (clientId: string) => void;
330
309
  readonly #onStateChange: (state: LeadershipState) => void;
331
310
  readonly #callTimeoutMs: number;
311
+ /** Quiet period before a bound follower probes; also the blocked re-probe interval. */
312
+ readonly #probeAfterMs: number;
332
313
  readonly #queueLimit: number;
314
+ readonly #schedule: FollowerSchedule;
333
315
  readonly #onMessage: (event: { data: MultiTabMessage }) => void;
334
316
 
335
317
  /** -1 until the first `announce` binds us to a leader. */
@@ -345,8 +327,8 @@ export class FollowerLink {
345
327
  state: 'waiting',
346
328
  reason: 'leader-announcement',
347
329
  };
348
- #waitingTimer: ReturnType<typeof setTimeout> | undefined;
349
- #blockedTimer: ReturnType<typeof setTimeout> | undefined;
330
+ #cancelProbe: (() => void) | undefined;
331
+ #cancelBlocked: (() => void) | undefined;
350
332
  /** Resolvers waiting for the first `announce` to bind a leader. */
351
333
  #bindWaiters: Array<() => void> = [];
352
334
 
@@ -358,6 +340,7 @@ export class FollowerLink {
358
340
  onStateChange?: (state: LeadershipState) => void;
359
341
  callTimeoutMs?: number;
360
342
  queueLimit?: number;
343
+ schedule?: FollowerSchedule;
361
344
  }) {
362
345
  this.#channel = options.channel;
363
346
  this.#fromId = options.fromId;
@@ -366,12 +349,19 @@ export class FollowerLink {
366
349
  this.#onStateChange = options.onStateChange ?? (() => {});
367
350
  this.#callTimeoutMs =
368
351
  options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
352
+ this.#probeAfterMs = Math.max(1, Math.floor(this.#callTimeoutMs / 3));
369
353
  this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
354
+ this.#schedule =
355
+ options.schedule ??
356
+ ((callback, delayMs) => {
357
+ const timer = setTimeout(callback, delayMs);
358
+ return () => clearTimeout(timer);
359
+ });
370
360
  this.#onMessage = (event) => this.#handle(event.data);
371
361
  this.#channel.addEventListener('message', this.#onMessage);
372
- // Ask the current leader to announce itself.
373
- this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
374
362
  this.#armUnboundDeadline();
363
+ // Ask the current leader to announce itself.
364
+ this.#probe();
375
365
  }
376
366
 
377
367
  get epoch(): number {
@@ -413,7 +403,7 @@ export class FollowerLink {
413
403
  );
414
404
  }
415
405
  return new Promise((resolve, reject) => {
416
- const timer = setTimeout(() => {
406
+ const cancelTimer = this.#schedule(() => {
417
407
  this.#dropBindWaiter(settle);
418
408
  this.#setBlocked();
419
409
  reject(
@@ -425,7 +415,7 @@ export class FollowerLink {
425
415
  );
426
416
  }, timeoutMs);
427
417
  const settle = (): void => {
428
- clearTimeout(timer);
418
+ cancelTimer();
429
419
  resolve();
430
420
  };
431
421
  this.#bindWaiters.push(settle);
@@ -466,7 +456,7 @@ export class FollowerLink {
466
456
  args,
467
457
  resolve,
468
458
  reject,
469
- timer: undefined,
459
+ cancelTimer: undefined,
470
460
  };
471
461
  if (this.#epoch < 0) {
472
462
  // No leader bound yet — queue with a deadline so we never hang.
@@ -481,7 +471,7 @@ export class FollowerLink {
481
471
  );
482
472
  return;
483
473
  }
484
- queued.timer = setTimeout(() => {
474
+ queued.cancelTimer = this.#schedule(() => {
485
475
  this.#dropQueued(queued);
486
476
  this.#setBlocked();
487
477
  reject(
@@ -501,12 +491,14 @@ export class FollowerLink {
501
491
 
502
492
  #send(queued: QueuedCall): void {
503
493
  const reqId = this.#nextReqId++;
504
- const inflight: InFlight = {
494
+ // The deadline fails this one call. Leader reachability is decided by the
495
+ // liveness probe alone, so a slow call against a live leader leaves the
496
+ // link bound.
497
+ this.#inFlight.set(reqId, {
505
498
  resolve: queued.resolve,
506
499
  reject: queued.reject,
507
- timer: setTimeout(() => {
500
+ cancelTimer: this.#schedule(() => {
508
501
  this.#inFlight.delete(reqId);
509
- this.#setBlocked();
510
502
  queued.reject(
511
503
  new ClientSyncError(
512
504
  FOLLOWER_TIMEOUT_CODE,
@@ -515,8 +507,7 @@ export class FollowerLink {
515
507
  ),
516
508
  );
517
509
  }, this.#callTimeoutMs),
518
- };
519
- this.#inFlight.set(reqId, inflight);
510
+ });
520
511
  this.#channel.postMessage({
521
512
  t: 'req',
522
513
  epoch: this.#epoch,
@@ -551,10 +542,7 @@ export class FollowerLink {
551
542
  leaderClientId: message.clientId,
552
543
  epoch: message.epoch,
553
544
  });
554
- this.#armBoundDeadline();
555
- if (changed) {
556
- this.#channel.postMessage({ t: 'bound', fromId: this.#fromId });
557
- }
545
+ this.#armLivenessProbe();
558
546
  this.#resolveBindWaiters();
559
547
  this.#flushQueue();
560
548
  return;
@@ -566,7 +554,7 @@ export class FollowerLink {
566
554
  const inflight = this.#inFlight.get(message.reqId);
567
555
  if (inflight === undefined) return;
568
556
  this.#inFlight.delete(message.reqId);
569
- if (inflight.timer !== undefined) clearTimeout(inflight.timer);
557
+ inflight.cancelTimer();
570
558
  if (message.ok) {
571
559
  inflight.resolve(message.value);
572
560
  } else {
@@ -593,8 +581,8 @@ export class FollowerLink {
593
581
  const pending = this.#queue;
594
582
  this.#queue = [];
595
583
  for (const queued of pending) {
596
- if (queued.timer !== undefined) clearTimeout(queued.timer);
597
- queued.timer = undefined;
584
+ queued.cancelTimer?.();
585
+ queued.cancelTimer = undefined;
598
586
  this.#send(queued);
599
587
  }
600
588
  }
@@ -611,11 +599,7 @@ export class FollowerLink {
611
599
  this.#leaderClientId = '';
612
600
  this.#setState({ state: 'waiting', reason: 'handover' });
613
601
  this.#armUnboundDeadline();
614
- this.#channel.postMessage({
615
- t: 'hello',
616
- fromId: this.#fromId,
617
- epoch: this.#maxEpochSeen,
618
- });
602
+ this.#probe();
619
603
  }
620
604
 
621
605
  close(): void {
@@ -623,18 +607,17 @@ export class FollowerLink {
623
607
  this.#closed = true;
624
608
  this.#clearReachabilityTimers();
625
609
  this.#channel.removeEventListener('message', this.#onMessage);
626
- this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
627
610
  const closedError = new ClientSyncError(
628
611
  WORKER_FAILED_CODE,
629
612
  'the follower link was closed',
630
613
  );
631
614
  for (const inflight of this.#inFlight.values()) {
632
- if (inflight.timer !== undefined) clearTimeout(inflight.timer);
615
+ inflight.cancelTimer();
633
616
  inflight.reject(closedError);
634
617
  }
635
618
  this.#inFlight.clear();
636
619
  for (const queued of this.#queue) {
637
- if (queued.timer !== undefined) clearTimeout(queued.timer);
620
+ queued.cancelTimer?.();
638
621
  queued.reject(closedError);
639
622
  }
640
623
  this.#queue = [];
@@ -667,6 +650,19 @@ export class FollowerLink {
667
650
  }
668
651
  }
669
652
 
653
+ /** Ask the leader to announce itself; every live leader answers a `hello`. */
654
+ #probe(): void {
655
+ this.#channel.postMessage({
656
+ t: 'hello',
657
+ fromId: this.#fromId,
658
+ epoch: this.#maxEpochSeen,
659
+ });
660
+ }
661
+
662
+ /**
663
+ * No leader answered in time. Calls reject immediately from here on; keep
664
+ * probing so a leader that answers again rebinds this link.
665
+ */
670
666
  #setBlocked(): void {
671
667
  this.#clearReachabilityTimers();
672
668
  this.#setState({
@@ -675,34 +671,45 @@ export class FollowerLink {
675
671
  code: FOLLOWER_TIMEOUT_CODE,
676
672
  retryable: true,
677
673
  });
674
+ const reprobe = (): void => {
675
+ this.#cancelProbe = this.#schedule(() => {
676
+ reprobe();
677
+ this.#probe();
678
+ }, this.#probeAfterMs);
679
+ };
680
+ reprobe();
678
681
  }
679
682
 
680
683
  #armUnboundDeadline(): void {
681
684
  this.#clearReachabilityTimers();
682
- this.#blockedTimer = setTimeout(
685
+ this.#cancelBlocked = this.#schedule(
683
686
  () => this.#setBlocked(),
684
687
  this.#callTimeoutMs,
685
688
  );
686
689
  }
687
690
 
688
- #armBoundDeadline(): void {
691
+ /**
692
+ * An announce just proved the leader alive. After a quiet period, probe it;
693
+ * block only if that probe stays unanswered for the rest of the call
694
+ * timeout. The deadline is armed before the probe is posted so a reply that
695
+ * arrives synchronously clears it.
696
+ */
697
+ #armLivenessProbe(): void {
689
698
  this.#clearReachabilityTimers();
690
- this.#waitingTimer = setTimeout(
691
- () => {
692
- this.#setState({ state: 'waiting', reason: 'leader-announcement' });
693
- },
694
- Math.max(1, Math.floor((this.#callTimeoutMs * 2) / 3)),
695
- );
696
- this.#blockedTimer = setTimeout(
697
- () => this.#setBlocked(),
698
- this.#callTimeoutMs,
699
- );
699
+ this.#cancelProbe = this.#schedule(() => {
700
+ this.#cancelProbe = undefined;
701
+ this.#cancelBlocked = this.#schedule(
702
+ () => this.#setBlocked(),
703
+ this.#callTimeoutMs - this.#probeAfterMs,
704
+ );
705
+ this.#probe();
706
+ }, this.#probeAfterMs);
700
707
  }
701
708
 
702
709
  #clearReachabilityTimers(): void {
703
- if (this.#waitingTimer !== undefined) clearTimeout(this.#waitingTimer);
704
- if (this.#blockedTimer !== undefined) clearTimeout(this.#blockedTimer);
705
- this.#waitingTimer = undefined;
706
- this.#blockedTimer = undefined;
710
+ this.#cancelProbe?.();
711
+ this.#cancelBlocked?.();
712
+ this.#cancelProbe = undefined;
713
+ this.#cancelBlocked = undefined;
707
714
  }
708
715
  }
@@ -529,7 +529,10 @@ export class RealtimeSupervisor {
529
529
  if (this.#stopped) return;
530
530
  this.#diagnosticConnectivity = snapshot.host.connectivity;
531
531
  this.#diagnosticSecurity = snapshot.securityLifecycle;
532
- if (snapshot.host.realtime === 'unsupported') {
532
+ if (
533
+ snapshot.host.realtime === 'unsupported' ||
534
+ snapshot.host.realtime === 'disabled'
535
+ ) {
533
536
  const disconnect =
534
537
  this.#connected || this.#transportConnected || this.#connecting;
535
538
  this.#realtimeSupported = false;
@@ -563,7 +566,18 @@ export class RealtimeSupervisor {
563
566
  void this.#adoptConnectedTransport();
564
567
  return;
565
568
  }
566
- if (snapshot.host.realtime === 'disconnected') {
569
+ if (snapshot.host.realtime === 'connecting') {
570
+ // An attempt is already in flight; the supervisor's own #connect (or
571
+ // the host's direct call) owns the outcome.
572
+ this.#transportConnected = false;
573
+ this.#connected = false;
574
+ return;
575
+ }
576
+ if (
577
+ snapshot.host.realtime === 'disconnected' ||
578
+ snapshot.host.realtime === 'lost' ||
579
+ snapshot.host.realtime === 'refused'
580
+ ) {
567
581
  this.#transportConnected = false;
568
582
  this.#connected = false;
569
583
  if (this.#connecting) return;
@@ -57,9 +57,14 @@ export interface SyncWorkerOverrides {
57
57
  readonly createSegments?: (
58
58
  config: WorkerInitConfig,
59
59
  ) => SegmentDownloader | undefined;
60
+ /**
61
+ * Custom realtime connector, e.g. one that mints a fresh ticket per
62
+ * connection attempt. `headers` reads the current host auth set.
63
+ */
60
64
  readonly createRealtime?: (
61
65
  config: WorkerInitConfig,
62
66
  clientId: string,
67
+ headers: () => Readonly<Record<string, string>>,
63
68
  ) => RealtimeConnector | undefined;
64
69
  }
65
70
 
@@ -129,6 +134,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
129
134
  let client: SyncClient | undefined;
130
135
  let database: ClientDatabase | undefined;
131
136
  let offline = false;
137
+ let headers: Readonly<Record<string, string>> = {};
132
138
  let closed = false;
133
139
 
134
140
  // -- one sync loop: RPC-driven and auto-driven rounds serialize ----------
@@ -266,6 +272,8 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
266
272
  );
267
273
  }
268
274
  autoSync = config.autoSync ?? true;
275
+ headers = config.headers ?? {};
276
+ const http = { headers: () => headers };
269
277
 
270
278
  for (let attempt = 0; ; attempt++) {
271
279
  try {
@@ -297,18 +305,18 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
297
305
  const transport = gateOffline(
298
306
  overrides.createTransport !== undefined
299
307
  ? overrides.createTransport(config)
300
- : httpSyncTransport(config.endpoints.syncUrl),
308
+ : httpSyncTransport(config.endpoints.syncUrl, http),
301
309
  );
302
310
  const segments =
303
311
  overrides.createSegments !== undefined
304
312
  ? overrides.createSegments(config)
305
313
  : config.endpoints.segmentsUrl !== undefined
306
- ? httpSegmentDownloader(config.endpoints.segmentsUrl)
314
+ ? httpSegmentDownloader(config.endpoints.segmentsUrl, http)
307
315
  : undefined;
308
316
  // §5.9 blob transport: only when a blobs URL is configured.
309
317
  const blobs =
310
318
  config.endpoints.blobsUrl !== undefined
311
- ? httpBlobTransport(config.endpoints.blobsUrl)
319
+ ? httpBlobTransport(config.endpoints.blobsUrl, http)
312
320
  : undefined;
313
321
 
314
322
  // Realtime needs the (possibly persisted) clientId for the
@@ -341,6 +349,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
341
349
  },
342
350
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
343
351
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
352
+ ...(config.realtimePolicy !== undefined
353
+ ? { realtimePolicy: config.realtimePolicy }
354
+ : {}),
344
355
  ...(config.encryption !== undefined
345
356
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
346
357
  : {}),
@@ -392,7 +403,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
392
403
  await started.start();
393
404
  realtimeConnector =
394
405
  overrides.createRealtime !== undefined
395
- ? overrides.createRealtime(config, started.clientId)
406
+ ? overrides.createRealtime(config, started.clientId, http.headers)
396
407
  : config.endpoints.realtimeUrl !== undefined
397
408
  ? webSocketRealtimeConnector(
398
409
  config.endpoints.realtimeUrl.replace(
@@ -490,6 +501,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
490
501
  presence: (scopeKey) => requireClient().presence(scopeKey),
491
502
  uploadBlob: (bytes, options) => requireClient().uploadBlob(bytes, options),
492
503
  fetchBlob: (blobIdOrRef) => requireClient().fetchBlob(blobIdOrRef),
504
+ setHeaders: (next) => {
505
+ headers = next;
506
+ },
493
507
  setOffline: (value) => {
494
508
  offline = value;
495
509
  if (offline) client?.disconnectRealtime();
@@ -50,6 +50,7 @@ import {
50
50
  type ClientDiagnosticsListener,
51
51
  type ClientDiagnosticsRequest,
52
52
  type ClientDiagnosticsSnapshot,
53
+ type RealtimePolicy,
53
54
  withClientDiagnosticsHost,
54
55
  } from './diagnostics';
55
56
  import type { EncryptionKeyringConfig } from './encryption';
@@ -180,12 +181,23 @@ export interface SyncClientHandleConfig {
180
181
  readonly schema: ClientSchema;
181
182
  readonly database: WorkerDatabaseInit;
182
183
  readonly endpoints: WorkerEndpoints;
184
+ /**
185
+ * Host auth headers for sync, segment, and blob requests (e.g.
186
+ * `Authorization`). Rotate with `setHeaders`. Realtime authenticates
187
+ * through `realtimeUrl`, because WebSockets cannot carry headers.
188
+ */
189
+ readonly headers?: Readonly<Record<string, string>>;
183
190
  /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
184
191
  readonly encryption?: EncryptionKeyringConfig;
185
192
  /** Open the worker-owned replica behind the fail-closed security gate. */
186
193
  readonly securityPreflight?: boolean;
187
194
  readonly clientId?: string;
188
195
  readonly limits?: SyncClientLimits;
196
+ /**
197
+ * SPEC §8.8 realtime policy for the worker-owned core, forwarded into
198
+ * `WorkerInitConfig`. Absent ⇒ `optional`.
199
+ */
200
+ readonly realtimePolicy?: RealtimePolicy;
189
201
  /**
190
202
  * RFC 0005 previous-version context, forwarded into the worker's
191
203
  * `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
@@ -208,7 +220,11 @@ export interface SyncClientHandleConfig {
208
220
  readonly multiTab?: boolean;
209
221
  /** Cross-tab channel factory (default `BroadcastChannel`); injectable for tests. */
210
222
  readonly channelFactory?: (name: string) => CrossTabChannel;
211
- /** Deadline for a follower call (covers the leader-handover gap). */
223
+ /**
224
+ * Deadline for a follower call (covers the leader-handover gap), and the
225
+ * bound on leader silence: a follower probes after a third of it and goes
226
+ * `blocked` when the probe stays unanswered for the rest. Default 10 s.
227
+ */
212
228
  readonly followerCallTimeoutMs?: number;
213
229
  /** Fires when this handle's role changes (follower → leader on promotion). */
214
230
  readonly onRoleChange?: (role: HandleRole) => void;
@@ -689,6 +705,19 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
689
705
  return this.#call('fetchBlob', [blobIdOrRef]);
690
706
  }
691
707
 
708
+ /**
709
+ * Replace the full host auth header set. The next HTTP request uses it;
710
+ * a live realtime socket keeps its handshake credentials. A follower
711
+ * forwards to the leader and keeps the set for its own promotion.
712
+ */
713
+ setHeaders(headers: Readonly<Record<string, string>>): Promise<void> {
714
+ this.__headers = headers;
715
+ return this.#call('setHeaders', [headers]);
716
+ }
717
+
718
+ /** @internal — the latest `setHeaders` set, carried into a promotion. */
719
+ __headers: Readonly<Record<string, string>> | undefined;
720
+
692
721
  /** Sever/restore transport + realtime inside the worker (demos). */
693
722
  setOffline(offline: boolean): Promise<void> {
694
723
  return this.#call('setOffline', [offline]);
@@ -839,11 +868,15 @@ async function startWorkerCore(options: {
839
868
  }
840
869
  }
841
870
 
842
- function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
871
+ function buildInitConfig(
872
+ config: SyncClientHandleConfig,
873
+ headers = config.headers,
874
+ ): WorkerInitConfig {
843
875
  return {
844
876
  schema: config.schema,
845
877
  database: config.database,
846
878
  endpoints: config.endpoints,
879
+ ...(headers !== undefined ? { headers } : {}),
847
880
  ...(config.encryption !== undefined
848
881
  ? { encryption: config.encryption }
849
882
  : {}),
@@ -852,6 +885,9 @@ function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
852
885
  : {}),
853
886
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
854
887
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
888
+ ...(config.realtimePolicy !== undefined
889
+ ? { realtimePolicy: config.realtimePolicy }
890
+ : {}),
855
891
  ...(config.previousVersionContext !== undefined
856
892
  ? { previousVersionContext: config.previousVersionContext }
857
893
  : {}),
@@ -997,10 +1033,6 @@ async function bootLeader(
997
1033
  epoch: parts.epoch ?? 0,
998
1034
  clientId,
999
1035
  invoke,
1000
- heartbeatMs: Math.max(
1001
- 10,
1002
- Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3),
1003
- ),
1004
1036
  });
1005
1037
  }
1006
1038
  : undefined;
@@ -1086,7 +1118,7 @@ async function bootFollower(
1086
1118
  try {
1087
1119
  const core = await startWorkerCore({
1088
1120
  config,
1089
- initConfig: buildInitConfig(config),
1121
+ initConfig: buildInitConfig(config, handle.__headers ?? config.headers),
1090
1122
  lease,
1091
1123
  dispatchEvent: (event) => {
1092
1124
  fireConfigCallbacks(config, event);
@@ -1107,10 +1139,6 @@ async function bootFollower(
1107
1139
  epoch: nextEpoch,
1108
1140
  clientId,
1109
1141
  invoke,
1110
- heartbeatMs: Math.max(
1111
- 10,
1112
- Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3),
1113
- ),
1114
1142
  });
1115
1143
  },
1116
1144
  }