@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/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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
349
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
queued.
|
|
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.#
|
|
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
|
-
|
|
615
|
+
inflight.cancelTimer();
|
|
633
616
|
inflight.reject(closedError);
|
|
634
617
|
}
|
|
635
618
|
this.#inFlight.clear();
|
|
636
619
|
for (const queued of this.#queue) {
|
|
637
|
-
|
|
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.#
|
|
685
|
+
this.#cancelBlocked = this.#schedule(
|
|
683
686
|
() => this.#setBlocked(),
|
|
684
687
|
this.#callTimeoutMs,
|
|
685
688
|
);
|
|
686
689
|
}
|
|
687
690
|
|
|
688
|
-
|
|
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.#
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
this.#
|
|
706
|
-
this.#
|
|
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 (
|
|
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 === '
|
|
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;
|
package/src/worker-entry.ts
CHANGED
|
@@ -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();
|
package/src/worker-host.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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(
|
|
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
|
}
|