@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/dist/multi-tab.js 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
@@ -73,23 +84,17 @@ export class LeaderBridge {
73
84
  #clientId;
74
85
  #invoke;
75
86
  #onMessage;
76
- #heartbeatMs;
77
- #followers = new Set();
78
- #heartbeat;
79
87
  #closed = false;
80
88
  constructor(options) {
81
89
  this.#channel = options.channel;
82
90
  this.#epoch = options.epoch;
83
91
  this.#clientId = options.clientId;
84
92
  this.#invoke = options.invoke;
85
- this.#heartbeatMs =
86
- options.heartbeatMs ??
87
- Math.max(50, Math.floor(DEFAULT_FOLLOWER_CALL_TIMEOUT_MS / 3));
88
93
  this.#onMessage = (event) => this.#handle(event.data);
89
94
  this.#channel.addEventListener('message', this.#onMessage);
90
95
  this.announce();
91
96
  }
92
- /** Re-announce leadership (on promotion and on a follower `hello`). */
97
+ /** Announce leadership (on promotion and in answer to every `hello`). */
93
98
  announce() {
94
99
  if (this.#closed)
95
100
  return;
@@ -109,23 +114,10 @@ export class LeaderBridge {
109
114
  if (this.#closed)
110
115
  return;
111
116
  if (message.t === 'hello') {
112
- // A follower joined (or is contesting) — tell it who leads.
113
- this.#trackFollower(message.fromId);
117
+ // A follower joined or probes liveness: tell it who leads.
114
118
  this.announce();
115
119
  return;
116
120
  }
117
- if (message.t === 'bound') {
118
- this.#trackFollower(message.fromId);
119
- return;
120
- }
121
- if (message.t === 'bye') {
122
- this.#followers.delete(message.fromId);
123
- if (this.#followers.size === 0 && this.#heartbeat !== undefined) {
124
- clearInterval(this.#heartbeat);
125
- this.#heartbeat = undefined;
126
- }
127
- return;
128
- }
129
121
  if (message.t !== 'req')
130
122
  return;
131
123
  // Ignore requests stamped for a different (dead/older) leader; that
@@ -162,19 +154,10 @@ export class LeaderBridge {
162
154
  });
163
155
  });
164
156
  }
165
- #trackFollower(fromId) {
166
- this.#followers.add(fromId);
167
- if (this.#heartbeat !== undefined)
168
- return;
169
- this.#heartbeat = setInterval(() => this.announce(), this.#heartbeatMs);
170
- }
171
157
  close() {
172
158
  if (this.#closed)
173
159
  return;
174
160
  this.#closed = true;
175
- if (this.#heartbeat !== undefined)
176
- clearInterval(this.#heartbeat);
177
- this.#heartbeat = undefined;
178
161
  this.#channel.removeEventListener('message', this.#onMessage);
179
162
  this.#channel.close();
180
163
  }
@@ -184,7 +167,9 @@ export class LeaderBridge {
184
167
  * matching `res`; queues calls (bounded, timed) while no leader is bound (the
185
168
  * handover gap) and flushes them once an `announce` binds a new leader.
186
169
  * Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
187
- * the resolved leader `clientId` back through `onLeaderChange`.
170
+ * the resolved leader `clientId` back through `onLeaderChange`. Probes the
171
+ * bound leader with `hello` after a quiet period and reports `blocked` only
172
+ * when a probe goes unanswered (see the module comment).
188
173
  */
189
174
  export class FollowerLink {
190
175
  #channel;
@@ -193,7 +178,10 @@ export class FollowerLink {
193
178
  #onLeaderChange;
194
179
  #onStateChange;
195
180
  #callTimeoutMs;
181
+ /** Quiet period before a bound follower probes; also the blocked re-probe interval. */
182
+ #probeAfterMs;
196
183
  #queueLimit;
184
+ #schedule;
197
185
  #onMessage;
198
186
  /** -1 until the first `announce` binds us to a leader. */
199
187
  #epoch = -1;
@@ -208,8 +196,8 @@ export class FollowerLink {
208
196
  state: 'waiting',
209
197
  reason: 'leader-announcement',
210
198
  };
211
- #waitingTimer;
212
- #blockedTimer;
199
+ #cancelProbe;
200
+ #cancelBlocked;
213
201
  /** Resolvers waiting for the first `announce` to bind a leader. */
214
202
  #bindWaiters = [];
215
203
  constructor(options) {
@@ -220,12 +208,19 @@ export class FollowerLink {
220
208
  this.#onStateChange = options.onStateChange ?? (() => { });
221
209
  this.#callTimeoutMs =
222
210
  options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
211
+ this.#probeAfterMs = Math.max(1, Math.floor(this.#callTimeoutMs / 3));
223
212
  this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
213
+ this.#schedule =
214
+ options.schedule ??
215
+ ((callback, delayMs) => {
216
+ const timer = setTimeout(callback, delayMs);
217
+ return () => clearTimeout(timer);
218
+ });
224
219
  this.#onMessage = (event) => this.#handle(event.data);
225
220
  this.#channel.addEventListener('message', this.#onMessage);
226
- // Ask the current leader to announce itself.
227
- this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
228
221
  this.#armUnboundDeadline();
222
+ // Ask the current leader to announce itself.
223
+ this.#probe();
229
224
  }
230
225
  get epoch() {
231
226
  return this.#epoch;
@@ -260,13 +255,13 @@ export class FollowerLink {
260
255
  return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'));
261
256
  }
262
257
  return new Promise((resolve, reject) => {
263
- const timer = setTimeout(() => {
258
+ const cancelTimer = this.#schedule(() => {
264
259
  this.#dropBindWaiter(settle);
265
260
  this.#setBlocked();
266
261
  reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader announced within the follower bind timeout', true));
267
262
  }, timeoutMs);
268
263
  const settle = () => {
269
- clearTimeout(timer);
264
+ cancelTimer();
270
265
  resolve();
271
266
  };
272
267
  this.#bindWaiters.push(settle);
@@ -299,7 +294,7 @@ export class FollowerLink {
299
294
  args,
300
295
  resolve,
301
296
  reject,
302
- timer: undefined,
297
+ cancelTimer: undefined,
303
298
  };
304
299
  if (this.#epoch < 0) {
305
300
  // No leader bound yet — queue with a deadline so we never hang.
@@ -308,7 +303,7 @@ export class FollowerLink {
308
303
  reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'follower call queue overflow while awaiting a leader', true));
309
304
  return;
310
305
  }
311
- queued.timer = setTimeout(() => {
306
+ queued.cancelTimer = this.#schedule(() => {
312
307
  this.#dropQueued(queued);
313
308
  this.#setBlocked();
314
309
  reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader answered within the follower call timeout', true));
@@ -321,16 +316,17 @@ export class FollowerLink {
321
316
  }
322
317
  #send(queued) {
323
318
  const reqId = this.#nextReqId++;
324
- const inflight = {
319
+ // The deadline fails this one call. Leader reachability is decided by the
320
+ // liveness probe alone, so a slow call against a live leader leaves the
321
+ // link bound.
322
+ this.#inFlight.set(reqId, {
325
323
  resolve: queued.resolve,
326
324
  reject: queued.reject,
327
- timer: setTimeout(() => {
325
+ cancelTimer: this.#schedule(() => {
328
326
  this.#inFlight.delete(reqId);
329
- this.#setBlocked();
330
327
  queued.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the leader did not answer within the follower call timeout', true));
331
328
  }, this.#callTimeoutMs),
332
- };
333
- this.#inFlight.set(reqId, inflight);
329
+ });
334
330
  this.#channel.postMessage({
335
331
  t: 'req',
336
332
  epoch: this.#epoch,
@@ -366,10 +362,7 @@ export class FollowerLink {
366
362
  leaderClientId: message.clientId,
367
363
  epoch: message.epoch,
368
364
  });
369
- this.#armBoundDeadline();
370
- if (changed) {
371
- this.#channel.postMessage({ t: 'bound', fromId: this.#fromId });
372
- }
365
+ this.#armLivenessProbe();
373
366
  this.#resolveBindWaiters();
374
367
  this.#flushQueue();
375
368
  return;
@@ -383,8 +376,7 @@ export class FollowerLink {
383
376
  if (inflight === undefined)
384
377
  return;
385
378
  this.#inFlight.delete(message.reqId);
386
- if (inflight.timer !== undefined)
387
- clearTimeout(inflight.timer);
379
+ inflight.cancelTimer();
388
380
  if (message.ok) {
389
381
  inflight.resolve(message.value);
390
382
  }
@@ -406,9 +398,8 @@ export class FollowerLink {
406
398
  const pending = this.#queue;
407
399
  this.#queue = [];
408
400
  for (const queued of pending) {
409
- if (queued.timer !== undefined)
410
- clearTimeout(queued.timer);
411
- queued.timer = undefined;
401
+ queued.cancelTimer?.();
402
+ queued.cancelTimer = undefined;
412
403
  this.#send(queued);
413
404
  }
414
405
  }
@@ -425,11 +416,7 @@ export class FollowerLink {
425
416
  this.#leaderClientId = '';
426
417
  this.#setState({ state: 'waiting', reason: 'handover' });
427
418
  this.#armUnboundDeadline();
428
- this.#channel.postMessage({
429
- t: 'hello',
430
- fromId: this.#fromId,
431
- epoch: this.#maxEpochSeen,
432
- });
419
+ this.#probe();
433
420
  }
434
421
  close() {
435
422
  if (this.#closed)
@@ -437,17 +424,14 @@ export class FollowerLink {
437
424
  this.#closed = true;
438
425
  this.#clearReachabilityTimers();
439
426
  this.#channel.removeEventListener('message', this.#onMessage);
440
- this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
441
427
  const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the follower link was closed');
442
428
  for (const inflight of this.#inFlight.values()) {
443
- if (inflight.timer !== undefined)
444
- clearTimeout(inflight.timer);
429
+ inflight.cancelTimer();
445
430
  inflight.reject(closedError);
446
431
  }
447
432
  this.#inFlight.clear();
448
433
  for (const queued of this.#queue) {
449
- if (queued.timer !== undefined)
450
- clearTimeout(queued.timer);
434
+ queued.cancelTimer?.();
451
435
  queued.reject(closedError);
452
436
  }
453
437
  this.#queue = [];
@@ -477,6 +461,18 @@ export class FollowerLink {
477
461
  // A status listener must never break cross-tab coordination.
478
462
  }
479
463
  }
464
+ /** Ask the leader to announce itself; every live leader answers a `hello`. */
465
+ #probe() {
466
+ this.#channel.postMessage({
467
+ t: 'hello',
468
+ fromId: this.#fromId,
469
+ epoch: this.#maxEpochSeen,
470
+ });
471
+ }
472
+ /**
473
+ * No leader answered in time. Calls reject immediately from here on; keep
474
+ * probing so a leader that answers again rebinds this link.
475
+ */
480
476
  #setBlocked() {
481
477
  this.#clearReachabilityTimers();
482
478
  this.#setState({
@@ -485,24 +481,36 @@ export class FollowerLink {
485
481
  code: FOLLOWER_TIMEOUT_CODE,
486
482
  retryable: true,
487
483
  });
484
+ const reprobe = () => {
485
+ this.#cancelProbe = this.#schedule(() => {
486
+ reprobe();
487
+ this.#probe();
488
+ }, this.#probeAfterMs);
489
+ };
490
+ reprobe();
488
491
  }
489
492
  #armUnboundDeadline() {
490
493
  this.#clearReachabilityTimers();
491
- this.#blockedTimer = setTimeout(() => this.#setBlocked(), this.#callTimeoutMs);
494
+ this.#cancelBlocked = this.#schedule(() => this.#setBlocked(), this.#callTimeoutMs);
492
495
  }
493
- #armBoundDeadline() {
496
+ /**
497
+ * An announce just proved the leader alive. After a quiet period, probe it;
498
+ * block only if that probe stays unanswered for the rest of the call
499
+ * timeout. The deadline is armed before the probe is posted so a reply that
500
+ * arrives synchronously clears it.
501
+ */
502
+ #armLivenessProbe() {
494
503
  this.#clearReachabilityTimers();
495
- this.#waitingTimer = setTimeout(() => {
496
- this.#setState({ state: 'waiting', reason: 'leader-announcement' });
497
- }, Math.max(1, Math.floor((this.#callTimeoutMs * 2) / 3)));
498
- this.#blockedTimer = setTimeout(() => this.#setBlocked(), this.#callTimeoutMs);
504
+ this.#cancelProbe = this.#schedule(() => {
505
+ this.#cancelProbe = undefined;
506
+ this.#cancelBlocked = this.#schedule(() => this.#setBlocked(), this.#callTimeoutMs - this.#probeAfterMs);
507
+ this.#probe();
508
+ }, this.#probeAfterMs);
499
509
  }
500
510
  #clearReachabilityTimers() {
501
- if (this.#waitingTimer !== undefined)
502
- clearTimeout(this.#waitingTimer);
503
- if (this.#blockedTimer !== undefined)
504
- clearTimeout(this.#blockedTimer);
505
- this.#waitingTimer = undefined;
506
- this.#blockedTimer = undefined;
511
+ this.#cancelProbe?.();
512
+ this.#cancelBlocked?.();
513
+ this.#cancelProbe = undefined;
514
+ this.#cancelBlocked = undefined;
507
515
  }
508
516
  }
@@ -384,7 +384,8 @@ export class RealtimeSupervisor {
384
384
  return;
385
385
  this.#diagnosticConnectivity = snapshot.host.connectivity;
386
386
  this.#diagnosticSecurity = snapshot.securityLifecycle;
387
- if (snapshot.host.realtime === 'unsupported') {
387
+ if (snapshot.host.realtime === 'unsupported' ||
388
+ snapshot.host.realtime === 'disabled') {
388
389
  const disconnect = this.#connected || this.#transportConnected || this.#connecting;
389
390
  this.#realtimeSupported = false;
390
391
  this.#generation += 1;
@@ -420,7 +421,16 @@ export class RealtimeSupervisor {
420
421
  void this.#adoptConnectedTransport();
421
422
  return;
422
423
  }
423
- if (snapshot.host.realtime === 'disconnected') {
424
+ if (snapshot.host.realtime === 'connecting') {
425
+ // An attempt is already in flight; the supervisor's own #connect (or
426
+ // the host's direct call) owns the outcome.
427
+ this.#transportConnected = false;
428
+ this.#connected = false;
429
+ return;
430
+ }
431
+ if (snapshot.host.realtime === 'disconnected' ||
432
+ snapshot.host.realtime === 'lost' ||
433
+ snapshot.host.realtime === 'refused') {
424
434
  this.#transportConnected = false;
425
435
  this.#connected = false;
426
436
  if (this.#connecting)
@@ -8,7 +8,11 @@ export interface SyncWorkerOverrides {
8
8
  readonly waitForStorageRetry?: (delayMs: number) => Promise<void>;
9
9
  readonly createTransport?: (config: WorkerInitConfig) => SyncTransport;
10
10
  readonly createSegments?: (config: WorkerInitConfig) => SegmentDownloader | undefined;
11
- readonly createRealtime?: (config: WorkerInitConfig, clientId: string) => RealtimeConnector | undefined;
11
+ /**
12
+ * Custom realtime connector, e.g. one that mints a fresh ticket per
13
+ * connection attempt. `headers` reads the current host auth set.
14
+ */
15
+ readonly createRealtime?: (config: WorkerInitConfig, clientId: string, headers: () => Readonly<Record<string, string>>) => RealtimeConnector | undefined;
12
16
  }
13
17
  /**
14
18
  * Register the RPC server on the current worker scope. Call this once
@@ -74,6 +74,7 @@ export function startSyncWorker(overrides = {}) {
74
74
  let client;
75
75
  let database;
76
76
  let offline = false;
77
+ let headers = {};
77
78
  let closed = false;
78
79
  // -- one sync loop: RPC-driven and auto-driven rounds serialize ----------
79
80
  let syncChain = Promise.resolve();
@@ -183,6 +184,8 @@ export function startSyncWorker(overrides = {}) {
183
184
  throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
184
185
  }
185
186
  autoSync = config.autoSync ?? true;
187
+ headers = config.headers ?? {};
188
+ const http = { headers: () => headers };
186
189
  for (let attempt = 0;; attempt++) {
187
190
  try {
188
191
  database =
@@ -212,15 +215,15 @@ export function startSyncWorker(overrides = {}) {
212
215
  }
213
216
  const transport = gateOffline(overrides.createTransport !== undefined
214
217
  ? overrides.createTransport(config)
215
- : httpSyncTransport(config.endpoints.syncUrl));
218
+ : httpSyncTransport(config.endpoints.syncUrl, http));
216
219
  const segments = overrides.createSegments !== undefined
217
220
  ? overrides.createSegments(config)
218
221
  : config.endpoints.segmentsUrl !== undefined
219
- ? httpSegmentDownloader(config.endpoints.segmentsUrl)
222
+ ? httpSegmentDownloader(config.endpoints.segmentsUrl, http)
220
223
  : undefined;
221
224
  // §5.9 blob transport: only when a blobs URL is configured.
222
225
  const blobs = config.endpoints.blobsUrl !== undefined
223
- ? httpBlobTransport(config.endpoints.blobsUrl)
226
+ ? httpBlobTransport(config.endpoints.blobsUrl, http)
224
227
  : undefined;
225
228
  // Realtime needs the (possibly persisted) clientId for the
226
229
  // `{clientId}` URL placeholder — start the client first, then attach
@@ -245,6 +248,9 @@ export function startSyncWorker(overrides = {}) {
245
248
  },
246
249
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
247
250
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
251
+ ...(config.realtimePolicy !== undefined
252
+ ? { realtimePolicy: config.realtimePolicy }
253
+ : {}),
248
254
  ...(config.encryption !== undefined
249
255
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
250
256
  : {}),
@@ -299,7 +305,7 @@ export function startSyncWorker(overrides = {}) {
299
305
  await started.start();
300
306
  realtimeConnector =
301
307
  overrides.createRealtime !== undefined
302
- ? overrides.createRealtime(config, started.clientId)
308
+ ? overrides.createRealtime(config, started.clientId, http.headers)
303
309
  : config.endpoints.realtimeUrl !== undefined
304
310
  ? webSocketRealtimeConnector(config.endpoints.realtimeUrl.replace('{clientId}', encodeURIComponent(started.clientId)))
305
311
  : undefined;
@@ -383,6 +389,9 @@ export function startSyncWorker(overrides = {}) {
383
389
  presence: (scopeKey) => requireClient().presence(scopeKey),
384
390
  uploadBlob: (bytes, options) => requireClient().uploadBlob(bytes, options),
385
391
  fetchBlob: (blobIdOrRef) => requireClient().fetchBlob(blobIdOrRef),
392
+ setHeaders: (next) => {
393
+ headers = next;
394
+ },
386
395
  setOffline: (value) => {
387
396
  offline = value;
388
397
  if (offline)
@@ -27,7 +27,7 @@ import type { WakeReason } from '@syncular/core';
27
27
  import type { BlobRef, CachedBlob } from './blob.js';
28
28
  import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
29
29
  import type { SqlRow, SqlValue } from './database.js';
30
- import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot } from './diagnostics.js';
30
+ import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot, type RealtimePolicy } from './diagnostics.js';
31
31
  import type { EncryptionKeyringConfig } from './encryption.js';
32
32
  import { ClientSyncError } from './errors.js';
33
33
  import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
@@ -75,12 +75,23 @@ export interface SyncClientHandleConfig {
75
75
  readonly schema: ClientSchema;
76
76
  readonly database: WorkerDatabaseInit;
77
77
  readonly endpoints: WorkerEndpoints;
78
+ /**
79
+ * Host auth headers for sync, segment, and blob requests (e.g.
80
+ * `Authorization`). Rotate with `setHeaders`. Realtime authenticates
81
+ * through `realtimeUrl`, because WebSockets cannot carry headers.
82
+ */
83
+ readonly headers?: Readonly<Record<string, string>>;
78
84
  /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
79
85
  readonly encryption?: EncryptionKeyringConfig;
80
86
  /** Open the worker-owned replica behind the fail-closed security gate. */
81
87
  readonly securityPreflight?: boolean;
82
88
  readonly clientId?: string;
83
89
  readonly limits?: SyncClientLimits;
90
+ /**
91
+ * SPEC §8.8 realtime policy for the worker-owned core, forwarded into
92
+ * `WorkerInitConfig`. Absent ⇒ `optional`.
93
+ */
94
+ readonly realtimePolicy?: RealtimePolicy;
84
95
  /**
85
96
  * RFC 0005 previous-version context, forwarded into the worker's
86
97
  * `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
@@ -103,7 +114,11 @@ export interface SyncClientHandleConfig {
103
114
  readonly multiTab?: boolean;
104
115
  /** Cross-tab channel factory (default `BroadcastChannel`); injectable for tests. */
105
116
  readonly channelFactory?: (name: string) => CrossTabChannel;
106
- /** Deadline for a follower call (covers the leader-handover gap). */
117
+ /**
118
+ * Deadline for a follower call (covers the leader-handover gap), and the
119
+ * bound on leader silence: a follower probes after a third of it and goes
120
+ * `blocked` when the probe stays unanswered for the rest. Default 10 s.
121
+ */
107
122
  readonly followerCallTimeoutMs?: number;
108
123
  /** Fires when this handle's role changes (follower → leader on promotion). */
109
124
  readonly onRoleChange?: (role: HandleRole) => void;
@@ -245,6 +260,14 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
245
260
  readonly name?: string;
246
261
  }): Promise<BlobRef>;
247
262
  fetchBlob(blobIdOrRef: string): Promise<CachedBlob>;
263
+ /**
264
+ * Replace the full host auth header set. The next HTTP request uses it;
265
+ * a live realtime socket keeps its handshake credentials. A follower
266
+ * forwards to the leader and keeps the set for its own promotion.
267
+ */
268
+ setHeaders(headers: Readonly<Record<string, string>>): Promise<void>;
269
+ /** @internal — the latest `setHeaders` set, carried into a promotion. */
270
+ __headers: Readonly<Record<string, string>> | undefined;
248
271
  /** Sever/restore transport + realtime inside the worker (demos). */
249
272
  setOffline(offline: boolean): Promise<void>;
250
273
  /** Close the core (leader) or unbind the link (follower), release leadership. */
@@ -361,6 +361,17 @@ export class SyncClientHandle {
361
361
  fetchBlob(blobIdOrRef) {
362
362
  return this.#call('fetchBlob', [blobIdOrRef]);
363
363
  }
364
+ /**
365
+ * Replace the full host auth header set. The next HTTP request uses it;
366
+ * a live realtime socket keeps its handshake credentials. A follower
367
+ * forwards to the leader and keeps the set for its own promotion.
368
+ */
369
+ setHeaders(headers) {
370
+ this.__headers = headers;
371
+ return this.#call('setHeaders', [headers]);
372
+ }
373
+ /** @internal — the latest `setHeaders` set, carried into a promotion. */
374
+ __headers;
364
375
  /** Sever/restore transport + realtime inside the worker (demos). */
365
376
  setOffline(offline) {
366
377
  return this.#call('setOffline', [offline]);
@@ -487,11 +498,12 @@ async function startWorkerCore(options) {
487
498
  throw error;
488
499
  }
489
500
  }
490
- function buildInitConfig(config) {
501
+ function buildInitConfig(config, headers = config.headers) {
491
502
  return {
492
503
  schema: config.schema,
493
504
  database: config.database,
494
505
  endpoints: config.endpoints,
506
+ ...(headers !== undefined ? { headers } : {}),
495
507
  ...(config.encryption !== undefined
496
508
  ? { encryption: config.encryption }
497
509
  : {}),
@@ -500,6 +512,9 @@ function buildInitConfig(config) {
500
512
  : {}),
501
513
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
502
514
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
515
+ ...(config.realtimePolicy !== undefined
516
+ ? { realtimePolicy: config.realtimePolicy }
517
+ : {}),
503
518
  ...(config.previousVersionContext !== undefined
504
519
  ? { previousVersionContext: config.previousVersionContext }
505
520
  : {}),
@@ -615,7 +630,6 @@ async function bootLeader(config, lockName, lease, parts) {
615
630
  epoch: parts.epoch ?? 0,
616
631
  clientId,
617
632
  invoke,
618
- heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
619
633
  });
620
634
  }
621
635
  : undefined;
@@ -691,7 +705,7 @@ async function bootFollower(config, lockName, lock, parts) {
691
705
  try {
692
706
  const core = await startWorkerCore({
693
707
  config,
694
- initConfig: buildInitConfig(config),
708
+ initConfig: buildInitConfig(config, handle.__headers ?? config.headers),
695
709
  lease,
696
710
  dispatchEvent: (event) => {
697
711
  fireConfigCallbacks(config, event);
@@ -706,7 +720,6 @@ async function bootFollower(config, lockName, lock, parts) {
706
720
  epoch: nextEpoch,
707
721
  clientId,
708
722
  invoke,
709
- heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
710
723
  });
711
724
  },
712
725
  }
@@ -21,7 +21,7 @@ import type { WakeReason } from '@syncular/core';
21
21
  import type { BlobRef, CachedBlob } from './blob.js';
22
22
  import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
23
23
  import type { SqlRow, SqlValue } from './database.js';
24
- import type { ClientDiagnosticsSnapshot } from './diagnostics.js';
24
+ import type { ClientDiagnosticsSnapshot, RealtimePolicy } from './diagnostics.js';
25
25
  import type { EncryptionKeyringConfig } from './encryption.js';
26
26
  import type { ClientChangeBatch, LocalRevision } from './invalidation.js';
27
27
  import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
@@ -68,12 +68,23 @@ export interface WorkerInitConfig {
68
68
  readonly schema: ClientSchema;
69
69
  readonly database: WorkerDatabaseInit;
70
70
  readonly endpoints: WorkerEndpoints;
71
+ /**
72
+ * Host auth headers for sync, segment, and blob requests. `setHeaders`
73
+ * replaces the whole set at runtime. WebSockets cannot carry headers, so
74
+ * realtime authenticates through `realtimeUrl` (cookie or ticket).
75
+ */
76
+ readonly headers?: Readonly<Record<string, string>>;
71
77
  /** Portable raw keyring installed inside the worker-owned client core. */
72
78
  readonly encryption?: EncryptionKeyringConfig;
73
79
  /** Open the worker-owned replica behind the fail-closed security gate. */
74
80
  readonly securityPreflight?: boolean;
75
81
  readonly clientId?: string;
76
82
  readonly limits?: SyncClientLimits;
83
+ /**
84
+ * SPEC §8.8 realtime policy for the worker-owned core; forwarded verbatim.
85
+ * Absent ⇒ `optional` (a connected socket, otherwise `POST /sync`).
86
+ */
87
+ readonly realtimePolicy?: RealtimePolicy;
77
88
  /**
78
89
  * RFC 0005 previous-version context, forwarded verbatim into the worker's
79
90
  * `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
@@ -142,6 +153,8 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
142
153
  readonly name?: string;
143
154
  }): Promise<BlobRef>;
144
155
  fetchBlob(blobIdOrRef: string): Promise<CachedBlob>;
156
+ /** Replace the host auth header set; the next HTTP request uses it. */
157
+ setHeaders(headers: Readonly<Record<string, string>>): void;
145
158
  /** Sever/restore the transport + realtime (offline simulation, demos). */
146
159
  setOffline(offline: boolean): void;
147
160
  close(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.25.2",
3
+ "version": "0.26.1",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -89,10 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.25.2"
92
+ "@syncular/core": "0.26.1"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.25.2",
95
+ "@syncular/server": "0.26.1",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }