@syncular/client 0.15.36 → 0.15.38
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 +36 -2
- package/dist/client.js +33 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/realtime-supervisor.d.ts +84 -0
- package/dist/realtime-supervisor.js +468 -0
- package/package.json +3 -3
- package/src/client.ts +39 -4
- package/src/index.ts +1 -0
- package/src/realtime-supervisor.ts +620 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
const REALTIME_SUPERVISOR_KEY = Symbol.for('syncular.realtime-supervisor.v1');
|
|
2
|
+
const DEFAULT_INITIAL_DELAY_MS = 1_000;
|
|
3
|
+
const DEFAULT_MAXIMUM_DELAY_MS = 30_000;
|
|
4
|
+
const MAXIMUM_CONFIGURED_DELAY_MS = 300_000;
|
|
5
|
+
const UNSUPPORTED_SNAPSHOT = Object.freeze({
|
|
6
|
+
phase: 'unsupported',
|
|
7
|
+
attempt: 0,
|
|
8
|
+
});
|
|
9
|
+
const observationSources = new WeakMap();
|
|
10
|
+
function attachment(client, visited = new Set()) {
|
|
11
|
+
if (visited.has(client))
|
|
12
|
+
return undefined;
|
|
13
|
+
visited.add(client);
|
|
14
|
+
const candidate = Reflect.get(client, REALTIME_SUPERVISOR_KEY);
|
|
15
|
+
if (candidate?.version === 1 && candidate.supervisor) {
|
|
16
|
+
return candidate;
|
|
17
|
+
}
|
|
18
|
+
const source = observationSources.get(client);
|
|
19
|
+
return source === undefined ? undefined : attachment(source, visited);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Preserve supervisor observation across a facade without transferring
|
|
23
|
+
* transport ownership or exposing the source client. Binding packages use
|
|
24
|
+
* this when they normalize a client into another object identity.
|
|
25
|
+
*/
|
|
26
|
+
export function linkRealtimeSupervisorObservation(target, source) {
|
|
27
|
+
if (target !== source)
|
|
28
|
+
observationSources.set(target, source);
|
|
29
|
+
return target;
|
|
30
|
+
}
|
|
31
|
+
function scheduleTimer(callback, delayMs) {
|
|
32
|
+
const timer = globalThis.setTimeout(callback, delayMs);
|
|
33
|
+
return () => globalThis.clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
function boundedDelay(value, fallback) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return fallback;
|
|
38
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
39
|
+
throw new TypeError('realtime supervisor delays must be positive safe integers');
|
|
40
|
+
}
|
|
41
|
+
return Math.min(value, MAXIMUM_CONFIGURED_DELAY_MS);
|
|
42
|
+
}
|
|
43
|
+
function eventTarget(value) {
|
|
44
|
+
const candidate = value;
|
|
45
|
+
return typeof candidate?.addEventListener === 'function' &&
|
|
46
|
+
typeof candidate.removeEventListener === 'function'
|
|
47
|
+
? candidate
|
|
48
|
+
: undefined;
|
|
49
|
+
}
|
|
50
|
+
/** Browser/Tauri-webview connectivity evidence without importing DOM types. */
|
|
51
|
+
export function browserConnectivitySignal(options = {}) {
|
|
52
|
+
const root = globalThis;
|
|
53
|
+
const events = options.events ?? eventTarget(globalThis);
|
|
54
|
+
const network = options.network ?? root.navigator;
|
|
55
|
+
const current = () => {
|
|
56
|
+
if (network?.onLine === true)
|
|
57
|
+
return 'online';
|
|
58
|
+
if (network?.onLine === false)
|
|
59
|
+
return 'offline';
|
|
60
|
+
return 'unknown';
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
current,
|
|
64
|
+
subscribe(listener) {
|
|
65
|
+
if (events === undefined)
|
|
66
|
+
return () => undefined;
|
|
67
|
+
const notify = () => listener(current());
|
|
68
|
+
events.addEventListener('online', notify);
|
|
69
|
+
events.addEventListener('offline', notify);
|
|
70
|
+
return () => {
|
|
71
|
+
events.removeEventListener('online', notify);
|
|
72
|
+
events.removeEventListener('offline', notify);
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Browser/Tauri-webview visibility evidence without importing DOM types. */
|
|
78
|
+
export function documentLifecycleSignal(options = {}) {
|
|
79
|
+
const root = globalThis;
|
|
80
|
+
const document = options.document ?? root.document;
|
|
81
|
+
const events = options.events ?? eventTarget(document);
|
|
82
|
+
const classify = () => {
|
|
83
|
+
if (document?.visibilityState === 'visible')
|
|
84
|
+
return 'active';
|
|
85
|
+
if (document?.visibilityState === 'hidden')
|
|
86
|
+
return 'background';
|
|
87
|
+
return 'unknown';
|
|
88
|
+
};
|
|
89
|
+
let state = classify();
|
|
90
|
+
return {
|
|
91
|
+
current: () => state,
|
|
92
|
+
subscribe(listener) {
|
|
93
|
+
if (events === undefined)
|
|
94
|
+
return () => undefined;
|
|
95
|
+
const visibility = () => {
|
|
96
|
+
state = classify();
|
|
97
|
+
listener(state);
|
|
98
|
+
};
|
|
99
|
+
const background = () => {
|
|
100
|
+
state = 'background';
|
|
101
|
+
listener(state);
|
|
102
|
+
};
|
|
103
|
+
const active = () => {
|
|
104
|
+
state = classify() === 'background' ? 'background' : 'active';
|
|
105
|
+
listener(state);
|
|
106
|
+
};
|
|
107
|
+
events.addEventListener('visibilitychange', visibility);
|
|
108
|
+
events.addEventListener('pagehide', background);
|
|
109
|
+
events.addEventListener('pageshow', active);
|
|
110
|
+
return () => {
|
|
111
|
+
events.removeEventListener('visibilitychange', visibility);
|
|
112
|
+
events.removeEventListener('pagehide', background);
|
|
113
|
+
events.removeEventListener('pageshow', active);
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Supported host policy for Syncular's explicit realtime transport. It owns
|
|
120
|
+
* exactly one connect attempt, runs an explicit catch-up round before claiming
|
|
121
|
+
* connected, retries transient loss with bounded exponential jitter, and
|
|
122
|
+
* suspends across offline, background, or protected-preflight state.
|
|
123
|
+
*/
|
|
124
|
+
export class RealtimeSupervisor {
|
|
125
|
+
#client;
|
|
126
|
+
#connectivity;
|
|
127
|
+
#lifecycle;
|
|
128
|
+
#protection;
|
|
129
|
+
#scheduleTimer;
|
|
130
|
+
#random;
|
|
131
|
+
#initialDelayMs;
|
|
132
|
+
#maximumDelayMs;
|
|
133
|
+
#listeners = new Set();
|
|
134
|
+
#started = false;
|
|
135
|
+
#initialized = false;
|
|
136
|
+
#stopped = false;
|
|
137
|
+
#connected = false;
|
|
138
|
+
#transportConnected = false;
|
|
139
|
+
#connecting = false;
|
|
140
|
+
#realtimeSupported = true;
|
|
141
|
+
#diagnosticConnectivity = 'unknown';
|
|
142
|
+
#diagnosticSecurity = 'unknown';
|
|
143
|
+
#attempt = 0;
|
|
144
|
+
#generation = 0;
|
|
145
|
+
#snapshot = { phase: 'idle', attempt: 0 };
|
|
146
|
+
#cancelRetry;
|
|
147
|
+
#unsubscribeDiagnostics;
|
|
148
|
+
#unsubscribeConnectivity;
|
|
149
|
+
#unsubscribeLifecycle;
|
|
150
|
+
#unsubscribeProtection;
|
|
151
|
+
constructor(client, options = {}) {
|
|
152
|
+
this.#client = client;
|
|
153
|
+
this.#connectivity = options.connectivity;
|
|
154
|
+
this.#lifecycle = options.lifecycle;
|
|
155
|
+
this.#protection = options.protection;
|
|
156
|
+
this.#scheduleTimer = options.schedule ?? scheduleTimer;
|
|
157
|
+
this.#random = options.random ?? Math.random;
|
|
158
|
+
this.#initialDelayMs = boundedDelay(options.initialDelayMs, DEFAULT_INITIAL_DELAY_MS);
|
|
159
|
+
this.#maximumDelayMs = Math.max(this.#initialDelayMs, boundedDelay(options.maximumDelayMs, DEFAULT_MAXIMUM_DELAY_MS));
|
|
160
|
+
}
|
|
161
|
+
snapshot() {
|
|
162
|
+
return this.#snapshot;
|
|
163
|
+
}
|
|
164
|
+
subscribe(listener) {
|
|
165
|
+
this.#listeners.add(listener);
|
|
166
|
+
return () => this.#listeners.delete(listener);
|
|
167
|
+
}
|
|
168
|
+
start() {
|
|
169
|
+
if (this.#started || this.#stopped)
|
|
170
|
+
return;
|
|
171
|
+
this.#started = true;
|
|
172
|
+
this.#unsubscribeDiagnostics = this.#client.onDiagnostics((snapshot) => this.#observeDiagnostics(snapshot));
|
|
173
|
+
this.#unsubscribeConnectivity = this.#connectivity?.subscribe(() => this.#reconcileHostState());
|
|
174
|
+
this.#unsubscribeLifecycle = this.#lifecycle?.subscribe(() => this.#reconcileHostState());
|
|
175
|
+
this.#unsubscribeProtection = this.#protection?.subscribe(() => this.#reconcileHostState());
|
|
176
|
+
this.#reconcileHostState();
|
|
177
|
+
const generation = this.#generation;
|
|
178
|
+
void this.#initialize(generation);
|
|
179
|
+
}
|
|
180
|
+
stop() {
|
|
181
|
+
if (this.#stopped)
|
|
182
|
+
return;
|
|
183
|
+
this.#stopped = true;
|
|
184
|
+
this.#generation += 1;
|
|
185
|
+
const disconnect = this.#connected || this.#transportConnected || this.#connecting;
|
|
186
|
+
this.#connected = false;
|
|
187
|
+
this.#transportConnected = false;
|
|
188
|
+
this.#connecting = false;
|
|
189
|
+
this.#attempt = 0;
|
|
190
|
+
this.#clearRetry();
|
|
191
|
+
this.#unsubscribeDiagnostics?.();
|
|
192
|
+
this.#unsubscribeConnectivity?.();
|
|
193
|
+
this.#unsubscribeLifecycle?.();
|
|
194
|
+
this.#unsubscribeProtection?.();
|
|
195
|
+
this.#publish({ phase: 'stopped', attempt: 0 });
|
|
196
|
+
if (disconnect)
|
|
197
|
+
this.#disconnect();
|
|
198
|
+
}
|
|
199
|
+
async #initialize(generation) {
|
|
200
|
+
let snapshot;
|
|
201
|
+
try {
|
|
202
|
+
snapshot = await this.#client.diagnosticsSnapshot();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Protected or temporarily unavailable diagnostics must not become a
|
|
206
|
+
// startup dependency. Explicit host gates and the connect result remain.
|
|
207
|
+
}
|
|
208
|
+
if (generation !== this.#generation || this.#stopped)
|
|
209
|
+
return;
|
|
210
|
+
this.#initialized = true;
|
|
211
|
+
if (snapshot !== undefined)
|
|
212
|
+
this.#observeDiagnostics(snapshot, true);
|
|
213
|
+
this.#reconcileHostState();
|
|
214
|
+
}
|
|
215
|
+
#hostBlock() {
|
|
216
|
+
const protection = this.#protection?.current();
|
|
217
|
+
if (this.#diagnosticSecurity === 'preflight' ||
|
|
218
|
+
protection === 'preflight' ||
|
|
219
|
+
(this.#protection !== undefined && protection !== 'active')) {
|
|
220
|
+
return 'protected';
|
|
221
|
+
}
|
|
222
|
+
if (this.#diagnosticConnectivity === 'offline' ||
|
|
223
|
+
this.#connectivity?.current() === 'offline') {
|
|
224
|
+
return 'offline';
|
|
225
|
+
}
|
|
226
|
+
if (this.#lifecycle?.current() === 'background')
|
|
227
|
+
return 'background';
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
#canConnect() {
|
|
231
|
+
return (this.#initialized &&
|
|
232
|
+
!this.#stopped &&
|
|
233
|
+
this.#realtimeSupported &&
|
|
234
|
+
!this.#hostBlock());
|
|
235
|
+
}
|
|
236
|
+
#publish(snapshot) {
|
|
237
|
+
if (this.#snapshot.phase === snapshot.phase &&
|
|
238
|
+
this.#snapshot.attempt === snapshot.attempt &&
|
|
239
|
+
this.#snapshot.retryDelayMs === snapshot.retryDelayMs) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.#snapshot = Object.freeze(snapshot);
|
|
243
|
+
for (const listener of this.#listeners) {
|
|
244
|
+
try {
|
|
245
|
+
listener();
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Observers cannot alter transport ownership or retry policy.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
#clearRetry() {
|
|
253
|
+
this.#cancelRetry?.();
|
|
254
|
+
this.#cancelRetry = undefined;
|
|
255
|
+
}
|
|
256
|
+
#schedule(delayMs) {
|
|
257
|
+
if (!this.#canConnect() ||
|
|
258
|
+
this.#connected ||
|
|
259
|
+
this.#connecting ||
|
|
260
|
+
this.#cancelRetry) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
this.#publish(delayMs > 0
|
|
264
|
+
? { phase: 'retrying', attempt: this.#attempt, retryDelayMs: delayMs }
|
|
265
|
+
: { phase: 'connecting', attempt: this.#attempt });
|
|
266
|
+
this.#cancelRetry = this.#scheduleTimer(() => {
|
|
267
|
+
this.#cancelRetry = undefined;
|
|
268
|
+
void this.#connect();
|
|
269
|
+
}, delayMs);
|
|
270
|
+
}
|
|
271
|
+
#retryDelay() {
|
|
272
|
+
const exponential = Math.min(this.#maximumDelayMs, this.#initialDelayMs * 2 ** this.#attempt);
|
|
273
|
+
const random = this.#random();
|
|
274
|
+
const boundedRandom = Number.isFinite(random)
|
|
275
|
+
? Math.max(0, Math.min(1, random))
|
|
276
|
+
: 0;
|
|
277
|
+
const jitter = Math.floor(exponential * 0.25 * boundedRandom);
|
|
278
|
+
return Math.min(this.#maximumDelayMs, exponential + jitter);
|
|
279
|
+
}
|
|
280
|
+
#scheduleRetry() {
|
|
281
|
+
if (!this.#canConnect()) {
|
|
282
|
+
this.#reconcileHostState();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const delay = this.#retryDelay();
|
|
286
|
+
this.#attempt = Math.min(32, this.#attempt + 1);
|
|
287
|
+
this.#schedule(delay);
|
|
288
|
+
}
|
|
289
|
+
async #connect() {
|
|
290
|
+
if (!this.#canConnect() || this.#connected || this.#connecting)
|
|
291
|
+
return;
|
|
292
|
+
this.#connecting = true;
|
|
293
|
+
this.#publish({ phase: 'connecting', attempt: this.#attempt });
|
|
294
|
+
const generation = this.#generation;
|
|
295
|
+
let failed = false;
|
|
296
|
+
try {
|
|
297
|
+
await this.#client.connectRealtime();
|
|
298
|
+
this.#transportConnected = true;
|
|
299
|
+
if (generation !== this.#generation || !this.#canConnect()) {
|
|
300
|
+
this.#disconnect();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
await this.#catchUpConnectedTransport(generation);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
failed = true;
|
|
307
|
+
this.#connected = false;
|
|
308
|
+
this.#transportConnected = false;
|
|
309
|
+
this.#disconnect();
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
this.#connecting = false;
|
|
313
|
+
}
|
|
314
|
+
if (failed && generation === this.#generation)
|
|
315
|
+
this.#scheduleRetry();
|
|
316
|
+
}
|
|
317
|
+
async #catchUpConnectedTransport(generation) {
|
|
318
|
+
await this.#client.syncUntilIdle();
|
|
319
|
+
if (generation !== this.#generation ||
|
|
320
|
+
!this.#canConnect() ||
|
|
321
|
+
!this.#transportConnected) {
|
|
322
|
+
this.#disconnect();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.#connected = true;
|
|
326
|
+
this.#attempt = 0;
|
|
327
|
+
this.#publish({ phase: 'connected', attempt: 0 });
|
|
328
|
+
}
|
|
329
|
+
async #adoptConnectedTransport() {
|
|
330
|
+
if (!this.#canConnect() || this.#connecting || !this.#transportConnected) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
this.#connecting = true;
|
|
334
|
+
this.#clearRetry();
|
|
335
|
+
this.#publish({ phase: 'connecting', attempt: this.#attempt });
|
|
336
|
+
const generation = this.#generation;
|
|
337
|
+
let failed = false;
|
|
338
|
+
try {
|
|
339
|
+
await this.#catchUpConnectedTransport(generation);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
failed = true;
|
|
343
|
+
this.#connected = false;
|
|
344
|
+
this.#transportConnected = false;
|
|
345
|
+
this.#disconnect();
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
this.#connecting = false;
|
|
349
|
+
}
|
|
350
|
+
if (failed && generation === this.#generation)
|
|
351
|
+
this.#scheduleRetry();
|
|
352
|
+
}
|
|
353
|
+
#observeDiagnostics(snapshot, initial = false) {
|
|
354
|
+
if (this.#stopped)
|
|
355
|
+
return;
|
|
356
|
+
this.#diagnosticConnectivity = snapshot.host.connectivity;
|
|
357
|
+
this.#diagnosticSecurity = snapshot.securityLifecycle;
|
|
358
|
+
if (snapshot.host.realtime === 'unsupported') {
|
|
359
|
+
const disconnect = this.#connected || this.#transportConnected || this.#connecting;
|
|
360
|
+
this.#realtimeSupported = false;
|
|
361
|
+
this.#generation += 1;
|
|
362
|
+
this.#connected = false;
|
|
363
|
+
this.#transportConnected = false;
|
|
364
|
+
this.#attempt = 0;
|
|
365
|
+
this.#clearRetry();
|
|
366
|
+
this.#publish({ phase: 'unsupported', attempt: 0 });
|
|
367
|
+
if (disconnect)
|
|
368
|
+
this.#disconnect();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!this.#realtimeSupported)
|
|
372
|
+
return;
|
|
373
|
+
this.#realtimeSupported = true;
|
|
374
|
+
const block = this.#hostBlock();
|
|
375
|
+
if (block) {
|
|
376
|
+
this.#suspend(block);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (snapshot.host.realtime === 'connected') {
|
|
380
|
+
this.#transportConnected = true;
|
|
381
|
+
if (this.#connecting)
|
|
382
|
+
return;
|
|
383
|
+
this.#connected = false;
|
|
384
|
+
void this.#adoptConnectedTransport();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (snapshot.host.realtime === 'disconnected') {
|
|
388
|
+
this.#transportConnected = false;
|
|
389
|
+
this.#connected = false;
|
|
390
|
+
if (this.#connecting)
|
|
391
|
+
return;
|
|
392
|
+
if (this.#initialized && !initial)
|
|
393
|
+
this.#scheduleRetry();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
this.#reconcileHostState();
|
|
397
|
+
}
|
|
398
|
+
#reconcileHostState() {
|
|
399
|
+
if (this.#stopped)
|
|
400
|
+
return;
|
|
401
|
+
const block = this.#hostBlock();
|
|
402
|
+
if (block) {
|
|
403
|
+
this.#suspend(block);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (!this.#initialized) {
|
|
407
|
+
this.#publish({ phase: 'idle', attempt: 0 });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (!this.#realtimeSupported) {
|
|
411
|
+
this.#publish({ phase: 'unsupported', attempt: 0 });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (this.#connected) {
|
|
415
|
+
this.#publish({ phase: 'connected', attempt: 0 });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
this.#schedule(0);
|
|
419
|
+
}
|
|
420
|
+
#suspend(phase) {
|
|
421
|
+
const disconnect = this.#connected || this.#transportConnected || this.#connecting;
|
|
422
|
+
const hadScheduledWork = this.#cancelRetry !== undefined;
|
|
423
|
+
const phaseChanged = this.#snapshot.phase !== phase;
|
|
424
|
+
if (!disconnect && !hadScheduledWork && !phaseChanged)
|
|
425
|
+
return;
|
|
426
|
+
this.#generation += 1;
|
|
427
|
+
this.#connected = false;
|
|
428
|
+
this.#transportConnected = false;
|
|
429
|
+
this.#connecting = false;
|
|
430
|
+
this.#attempt = 0;
|
|
431
|
+
this.#clearRetry();
|
|
432
|
+
this.#publish({ phase, attempt: 0 });
|
|
433
|
+
if (disconnect)
|
|
434
|
+
this.#disconnect();
|
|
435
|
+
}
|
|
436
|
+
#disconnect() {
|
|
437
|
+
void Promise.resolve(this.#client.disconnectRealtime()).catch(() => undefined);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/** Install one supervisor and make client disposal cancel it before close. */
|
|
441
|
+
export function installRealtimeSupervisor(client, options) {
|
|
442
|
+
if (attachment(client))
|
|
443
|
+
return client;
|
|
444
|
+
const supervisor = new RealtimeSupervisor(client, options);
|
|
445
|
+
Object.defineProperty(client, REALTIME_SUPERVISOR_KEY, {
|
|
446
|
+
configurable: false,
|
|
447
|
+
enumerable: false,
|
|
448
|
+
writable: false,
|
|
449
|
+
value: { version: 1, supervisor },
|
|
450
|
+
});
|
|
451
|
+
const close = client.close.bind(client);
|
|
452
|
+
Object.defineProperty(client, 'close', {
|
|
453
|
+
configurable: true,
|
|
454
|
+
writable: true,
|
|
455
|
+
value: async () => {
|
|
456
|
+
supervisor.stop();
|
|
457
|
+
await close();
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
supervisor.start();
|
|
461
|
+
return client;
|
|
462
|
+
}
|
|
463
|
+
export function realtimeSupervisorSnapshot(client) {
|
|
464
|
+
return attachment(client)?.supervisor.snapshot() ?? UNSUPPORTED_SNAPSHOT;
|
|
465
|
+
}
|
|
466
|
+
export function subscribeRealtimeSupervisor(client, listener) {
|
|
467
|
+
return (attachment(client)?.supervisor.subscribe(listener) ?? (() => undefined));
|
|
468
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.38",
|
|
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",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
84
|
-
"@syncular/core": "0.15.
|
|
84
|
+
"@syncular/core": "0.15.38"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"better-sqlite3": ">=11"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.15.
|
|
95
|
+
"@syncular/server": "0.15.38",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -545,6 +545,8 @@ export class SyncClient {
|
|
|
545
545
|
#conflicts: ConflictRecord[] = [];
|
|
546
546
|
#rejections: RejectionRecord[] = [];
|
|
547
547
|
#socket: RealtimeSocket | undefined;
|
|
548
|
+
#realtimeConnectPromise: Promise<void> | undefined;
|
|
549
|
+
#realtimeGeneration = 0;
|
|
548
550
|
#pendingRound: PendingRound | undefined;
|
|
549
551
|
#needsPull = false;
|
|
550
552
|
#syncing = false;
|
|
@@ -793,8 +795,7 @@ export class SyncClient {
|
|
|
793
795
|
async close(): Promise<void> {
|
|
794
796
|
this.#devtoolsUnregister?.();
|
|
795
797
|
this.#devtoolsUnregister = undefined;
|
|
796
|
-
this
|
|
797
|
-
this.#socket = undefined;
|
|
798
|
+
this.disconnectRealtime();
|
|
798
799
|
this.#abortPendingRound('client closed mid-round');
|
|
799
800
|
await this.#lease?.release();
|
|
800
801
|
this.#lease = undefined;
|
|
@@ -2758,10 +2759,32 @@ export class SyncClient {
|
|
|
2758
2759
|
// -- realtime (§8 client side) ----------------------------------------------
|
|
2759
2760
|
|
|
2760
2761
|
connectRealtime(): Promise<void> {
|
|
2761
|
-
|
|
2762
|
+
this.#requireActive();
|
|
2763
|
+
if (this.#socket !== undefined) return Promise.resolve();
|
|
2764
|
+
if (this.#realtimeConnectPromise !== undefined) {
|
|
2765
|
+
return this.#realtimeConnectPromise;
|
|
2766
|
+
}
|
|
2767
|
+
const generation = this.#realtimeGeneration;
|
|
2768
|
+
const task = this.#runProtectedAsync(() =>
|
|
2769
|
+
this.#connectRealtime(generation),
|
|
2770
|
+
);
|
|
2771
|
+
this.#realtimeConnectPromise = task;
|
|
2772
|
+
void task.then(
|
|
2773
|
+
() => {
|
|
2774
|
+
if (this.#realtimeConnectPromise === task) {
|
|
2775
|
+
this.#realtimeConnectPromise = undefined;
|
|
2776
|
+
}
|
|
2777
|
+
},
|
|
2778
|
+
() => {
|
|
2779
|
+
if (this.#realtimeConnectPromise === task) {
|
|
2780
|
+
this.#realtimeConnectPromise = undefined;
|
|
2781
|
+
}
|
|
2782
|
+
},
|
|
2783
|
+
);
|
|
2784
|
+
return task;
|
|
2762
2785
|
}
|
|
2763
2786
|
|
|
2764
|
-
async #connectRealtime(): Promise<void> {
|
|
2787
|
+
async #connectRealtime(generation: number): Promise<void> {
|
|
2765
2788
|
const connector = this.#config.realtime;
|
|
2766
2789
|
if (connector === undefined) {
|
|
2767
2790
|
throw new ClientSyncError(
|
|
@@ -2769,16 +2792,19 @@ export class SyncClient {
|
|
|
2769
2792
|
'no realtime connector configured',
|
|
2770
2793
|
);
|
|
2771
2794
|
}
|
|
2795
|
+
let openedSocket: RealtimeSocket | undefined;
|
|
2772
2796
|
const socket = await connector({
|
|
2773
2797
|
onText: (text) => this.#handleRealtimeText(text),
|
|
2774
2798
|
onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
|
|
2775
2799
|
onClose: () => {
|
|
2800
|
+
if (openedSocket === undefined || this.#socket !== openedSocket) return;
|
|
2776
2801
|
this.#socket = undefined;
|
|
2777
2802
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
2778
2803
|
this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
|
|
2779
2804
|
this.#emitDiagnostics();
|
|
2780
2805
|
},
|
|
2781
2806
|
});
|
|
2807
|
+
openedSocket = socket;
|
|
2782
2808
|
if (this.#securityLifecycle === 'preflight') {
|
|
2783
2809
|
socket.close();
|
|
2784
2810
|
throw new ClientSyncError(
|
|
@@ -2786,11 +2812,20 @@ export class SyncClient {
|
|
|
2786
2812
|
'realtime connected after the client entered security preflight',
|
|
2787
2813
|
);
|
|
2788
2814
|
}
|
|
2815
|
+
if (generation !== this.#realtimeGeneration || !this.#started) {
|
|
2816
|
+
socket.close();
|
|
2817
|
+
throw new ClientSyncError(
|
|
2818
|
+
'client.realtime_cancelled',
|
|
2819
|
+
'realtime connection was cancelled before activation',
|
|
2820
|
+
);
|
|
2821
|
+
}
|
|
2789
2822
|
this.#socket = socket;
|
|
2790
2823
|
this.#emitDiagnostics();
|
|
2791
2824
|
}
|
|
2792
2825
|
|
|
2793
2826
|
disconnectRealtime(): void {
|
|
2827
|
+
this.#realtimeGeneration += 1;
|
|
2828
|
+
this.#realtimeConnectPromise = undefined;
|
|
2794
2829
|
this.#socket?.close();
|
|
2795
2830
|
this.#socket = undefined;
|
|
2796
2831
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
package/src/index.ts
CHANGED