@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/README.md +35 -7
- package/dist/client.d.ts +10 -1
- package/dist/client.js +153 -63
- package/dist/diagnostics.d.ts +13 -2
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +23 -0
- package/dist/http.d.ts +6 -1
- package/dist/http.js +10 -6
- package/dist/multi-tab.d.ts +11 -12
- package/dist/multi-tab.js +86 -78
- package/dist/realtime-supervisor.js +12 -2
- package/dist/worker-entry.d.ts +5 -1
- package/dist/worker-entry.js +13 -4
- package/dist/worker-host.d.ts +25 -2
- package/dist/worker-host.js +17 -4
- package/dist/worker-protocol.d.ts +14 -1
- package/package.json +3 -3
- package/src/client.ts +212 -81
- package/src/diagnostics.ts +17 -2
- package/src/errors.ts +38 -1
- package/src/http.ts +21 -7
- package/src/multi-tab.ts +99 -92
- package/src/realtime-supervisor.ts +16 -2
- package/src/worker-entry.ts +18 -4
- package/src/worker-host.ts +39 -11
- package/src/worker-protocol.ts +14 -1
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
|
-
/**
|
|
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
|
|
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
|
-
#
|
|
212
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
494
|
+
this.#cancelBlocked = this.#schedule(() => this.#setBlocked(), this.#callTimeoutMs);
|
|
492
495
|
}
|
|
493
|
-
|
|
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.#
|
|
496
|
-
this.#
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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 === '
|
|
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)
|
package/dist/worker-entry.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/worker-entry.js
CHANGED
|
@@ -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)
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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. */
|
package/dist/worker-host.js
CHANGED
|
@@ -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.
|
|
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.
|
|
92
|
+
"@syncular/core": "0.26.1"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.26.1",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|