@syncular/client 0.15.36 → 0.15.37
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 +78 -0
- package/dist/realtime-supervisor.js +452 -0
- package/package.json +3 -3
- package/src/client.ts +39 -4
- package/src/index.ts +1 -0
- package/src/realtime-supervisor.ts +598 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import type { SecurityLifecycle } from './client';
|
|
2
|
+
import type {
|
|
3
|
+
ClientDiagnosticsConnectivity,
|
|
4
|
+
ClientDiagnosticsListener,
|
|
5
|
+
ClientDiagnosticsSnapshot,
|
|
6
|
+
} from './diagnostics';
|
|
7
|
+
|
|
8
|
+
type CancelTimer = () => void;
|
|
9
|
+
|
|
10
|
+
export interface RealtimeSupervisorClient {
|
|
11
|
+
connectRealtime(): Promise<void>;
|
|
12
|
+
disconnectRealtime(): void | Promise<void>;
|
|
13
|
+
syncUntilIdle(maxRounds?: number): unknown | Promise<unknown>;
|
|
14
|
+
diagnosticsSnapshot():
|
|
15
|
+
| ClientDiagnosticsSnapshot
|
|
16
|
+
| Promise<ClientDiagnosticsSnapshot>;
|
|
17
|
+
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
18
|
+
close(): void | Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RealtimeSupervisorSignal<State> {
|
|
22
|
+
current(): State;
|
|
23
|
+
subscribe(listener: (state: State) => void): () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type RealtimeSupervisorLifecycleState =
|
|
27
|
+
| 'active'
|
|
28
|
+
| 'background'
|
|
29
|
+
| 'unknown';
|
|
30
|
+
export type RealtimeSupervisorProtectionState = SecurityLifecycle | 'unknown';
|
|
31
|
+
type RealtimeSuspendedPhase = 'offline' | 'background' | 'protected';
|
|
32
|
+
|
|
33
|
+
export type RealtimeSupervisorPhase =
|
|
34
|
+
| 'idle'
|
|
35
|
+
| 'connecting'
|
|
36
|
+
| 'connected'
|
|
37
|
+
| 'retrying'
|
|
38
|
+
| RealtimeSuspendedPhase
|
|
39
|
+
| 'unsupported'
|
|
40
|
+
| 'stopped';
|
|
41
|
+
|
|
42
|
+
export interface RealtimeSupervisorSnapshot {
|
|
43
|
+
readonly phase: RealtimeSupervisorPhase;
|
|
44
|
+
/** One-based for retries; zero for the initial connection. */
|
|
45
|
+
readonly attempt: number;
|
|
46
|
+
/** Bounded host-policy delay, never server or transport prose. */
|
|
47
|
+
readonly retryDelayMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RealtimeSupervisorOptions {
|
|
51
|
+
/** Host online/offline evidence. Unknown remains connectable and observable. */
|
|
52
|
+
readonly connectivity?: RealtimeSupervisorSignal<ClientDiagnosticsConnectivity>;
|
|
53
|
+
/** Browser/native foreground evidence. Background always suspends the socket. */
|
|
54
|
+
readonly lifecycle?: RealtimeSupervisorSignal<RealtimeSupervisorLifecycleState>;
|
|
55
|
+
/** Publish preflight before draining keys so reconnect stops in the same turn. */
|
|
56
|
+
readonly protection?: RealtimeSupervisorSignal<RealtimeSupervisorProtectionState>;
|
|
57
|
+
/** Deterministic test/host timer seam. */
|
|
58
|
+
readonly schedule?: (callback: () => void, delayMs: number) => CancelTimer;
|
|
59
|
+
readonly random?: () => number;
|
|
60
|
+
readonly initialDelayMs?: number;
|
|
61
|
+
readonly maximumDelayMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RealtimeSupervisorEventTarget {
|
|
65
|
+
addEventListener(type: string, listener: () => void): void;
|
|
66
|
+
removeEventListener(type: string, listener: () => void): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface BrowserConnectivitySignalOptions {
|
|
70
|
+
readonly events?: RealtimeSupervisorEventTarget;
|
|
71
|
+
readonly network?: { readonly onLine?: boolean };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface DocumentLifecycleSignalOptions {
|
|
75
|
+
readonly events?: RealtimeSupervisorEventTarget;
|
|
76
|
+
readonly document?: { readonly visibilityState?: string };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const REALTIME_SUPERVISOR_KEY = Symbol.for('syncular.realtime-supervisor.v1');
|
|
80
|
+
const DEFAULT_INITIAL_DELAY_MS = 1_000;
|
|
81
|
+
const DEFAULT_MAXIMUM_DELAY_MS = 30_000;
|
|
82
|
+
const MAXIMUM_CONFIGURED_DELAY_MS = 300_000;
|
|
83
|
+
const UNSUPPORTED_SNAPSHOT: RealtimeSupervisorSnapshot = Object.freeze({
|
|
84
|
+
phase: 'unsupported',
|
|
85
|
+
attempt: 0,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
interface RealtimeSupervisorAttachment {
|
|
89
|
+
readonly version: 1;
|
|
90
|
+
readonly supervisor: RealtimeSupervisor;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function attachment(client: object): RealtimeSupervisorAttachment | undefined {
|
|
94
|
+
const candidate = Reflect.get(client, REALTIME_SUPERVISOR_KEY) as
|
|
95
|
+
| Partial<RealtimeSupervisorAttachment>
|
|
96
|
+
| undefined;
|
|
97
|
+
return candidate?.version === 1 && candidate.supervisor
|
|
98
|
+
? (candidate as RealtimeSupervisorAttachment)
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function scheduleTimer(callback: () => void, delayMs: number): CancelTimer {
|
|
103
|
+
const timer = globalThis.setTimeout(callback, delayMs);
|
|
104
|
+
return () => globalThis.clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function boundedDelay(value: number | undefined, fallback: number): number {
|
|
108
|
+
if (value === undefined) return fallback;
|
|
109
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
110
|
+
throw new TypeError(
|
|
111
|
+
'realtime supervisor delays must be positive safe integers',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return Math.min(value, MAXIMUM_CONFIGURED_DELAY_MS);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function eventTarget(
|
|
118
|
+
value: unknown,
|
|
119
|
+
): RealtimeSupervisorEventTarget | undefined {
|
|
120
|
+
const candidate = value as Partial<RealtimeSupervisorEventTarget> | undefined;
|
|
121
|
+
return typeof candidate?.addEventListener === 'function' &&
|
|
122
|
+
typeof candidate.removeEventListener === 'function'
|
|
123
|
+
? (candidate as RealtimeSupervisorEventTarget)
|
|
124
|
+
: undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Browser/Tauri-webview connectivity evidence without importing DOM types. */
|
|
128
|
+
export function browserConnectivitySignal(
|
|
129
|
+
options: BrowserConnectivitySignalOptions = {},
|
|
130
|
+
): RealtimeSupervisorSignal<ClientDiagnosticsConnectivity> {
|
|
131
|
+
const root = globalThis as { navigator?: { readonly onLine?: boolean } };
|
|
132
|
+
const events = options.events ?? eventTarget(globalThis);
|
|
133
|
+
const network = options.network ?? root.navigator;
|
|
134
|
+
const current = (): ClientDiagnosticsConnectivity => {
|
|
135
|
+
if (network?.onLine === true) return 'online';
|
|
136
|
+
if (network?.onLine === false) return 'offline';
|
|
137
|
+
return 'unknown';
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
current,
|
|
141
|
+
subscribe(listener) {
|
|
142
|
+
if (events === undefined) return () => undefined;
|
|
143
|
+
const notify = () => listener(current());
|
|
144
|
+
events.addEventListener('online', notify);
|
|
145
|
+
events.addEventListener('offline', notify);
|
|
146
|
+
return () => {
|
|
147
|
+
events.removeEventListener('online', notify);
|
|
148
|
+
events.removeEventListener('offline', notify);
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Browser/Tauri-webview visibility evidence without importing DOM types. */
|
|
155
|
+
export function documentLifecycleSignal(
|
|
156
|
+
options: DocumentLifecycleSignalOptions = {},
|
|
157
|
+
): RealtimeSupervisorSignal<RealtimeSupervisorLifecycleState> {
|
|
158
|
+
const root = globalThis as {
|
|
159
|
+
document?: { readonly visibilityState?: string };
|
|
160
|
+
};
|
|
161
|
+
const document = options.document ?? root.document;
|
|
162
|
+
const events = options.events ?? eventTarget(document);
|
|
163
|
+
const classify = (): RealtimeSupervisorLifecycleState => {
|
|
164
|
+
if (document?.visibilityState === 'visible') return 'active';
|
|
165
|
+
if (document?.visibilityState === 'hidden') return 'background';
|
|
166
|
+
return 'unknown';
|
|
167
|
+
};
|
|
168
|
+
let state = classify();
|
|
169
|
+
return {
|
|
170
|
+
current: () => state,
|
|
171
|
+
subscribe(listener) {
|
|
172
|
+
if (events === undefined) return () => undefined;
|
|
173
|
+
const visibility = () => {
|
|
174
|
+
state = classify();
|
|
175
|
+
listener(state);
|
|
176
|
+
};
|
|
177
|
+
const background = () => {
|
|
178
|
+
state = 'background';
|
|
179
|
+
listener(state);
|
|
180
|
+
};
|
|
181
|
+
const active = () => {
|
|
182
|
+
state = classify() === 'background' ? 'background' : 'active';
|
|
183
|
+
listener(state);
|
|
184
|
+
};
|
|
185
|
+
events.addEventListener('visibilitychange', visibility);
|
|
186
|
+
events.addEventListener('pagehide', background);
|
|
187
|
+
events.addEventListener('pageshow', active);
|
|
188
|
+
return () => {
|
|
189
|
+
events.removeEventListener('visibilitychange', visibility);
|
|
190
|
+
events.removeEventListener('pagehide', background);
|
|
191
|
+
events.removeEventListener('pageshow', active);
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Supported host policy for Syncular's explicit realtime transport. It owns
|
|
199
|
+
* exactly one connect attempt, runs an explicit catch-up round before claiming
|
|
200
|
+
* connected, retries transient loss with bounded exponential jitter, and
|
|
201
|
+
* suspends across offline, background, or protected-preflight state.
|
|
202
|
+
*/
|
|
203
|
+
export class RealtimeSupervisor {
|
|
204
|
+
readonly #client: RealtimeSupervisorClient;
|
|
205
|
+
readonly #connectivity?: RealtimeSupervisorOptions['connectivity'];
|
|
206
|
+
readonly #lifecycle?: RealtimeSupervisorOptions['lifecycle'];
|
|
207
|
+
readonly #protection?: RealtimeSupervisorOptions['protection'];
|
|
208
|
+
readonly #scheduleTimer: NonNullable<RealtimeSupervisorOptions['schedule']>;
|
|
209
|
+
readonly #random: () => number;
|
|
210
|
+
readonly #initialDelayMs: number;
|
|
211
|
+
readonly #maximumDelayMs: number;
|
|
212
|
+
readonly #listeners = new Set<() => void>();
|
|
213
|
+
|
|
214
|
+
#started = false;
|
|
215
|
+
#initialized = false;
|
|
216
|
+
#stopped = false;
|
|
217
|
+
#connected = false;
|
|
218
|
+
#transportConnected = false;
|
|
219
|
+
#connecting = false;
|
|
220
|
+
#realtimeSupported = true;
|
|
221
|
+
#diagnosticConnectivity: ClientDiagnosticsConnectivity = 'unknown';
|
|
222
|
+
#diagnosticSecurity: RealtimeSupervisorProtectionState = 'unknown';
|
|
223
|
+
#attempt = 0;
|
|
224
|
+
#generation = 0;
|
|
225
|
+
#snapshot: RealtimeSupervisorSnapshot = { phase: 'idle', attempt: 0 };
|
|
226
|
+
#cancelRetry: CancelTimer | undefined;
|
|
227
|
+
#unsubscribeDiagnostics: (() => void) | undefined;
|
|
228
|
+
#unsubscribeConnectivity: (() => void) | undefined;
|
|
229
|
+
#unsubscribeLifecycle: (() => void) | undefined;
|
|
230
|
+
#unsubscribeProtection: (() => void) | undefined;
|
|
231
|
+
|
|
232
|
+
constructor(
|
|
233
|
+
client: RealtimeSupervisorClient,
|
|
234
|
+
options: RealtimeSupervisorOptions = {},
|
|
235
|
+
) {
|
|
236
|
+
this.#client = client;
|
|
237
|
+
this.#connectivity = options.connectivity;
|
|
238
|
+
this.#lifecycle = options.lifecycle;
|
|
239
|
+
this.#protection = options.protection;
|
|
240
|
+
this.#scheduleTimer = options.schedule ?? scheduleTimer;
|
|
241
|
+
this.#random = options.random ?? Math.random;
|
|
242
|
+
this.#initialDelayMs = boundedDelay(
|
|
243
|
+
options.initialDelayMs,
|
|
244
|
+
DEFAULT_INITIAL_DELAY_MS,
|
|
245
|
+
);
|
|
246
|
+
this.#maximumDelayMs = Math.max(
|
|
247
|
+
this.#initialDelayMs,
|
|
248
|
+
boundedDelay(options.maximumDelayMs, DEFAULT_MAXIMUM_DELAY_MS),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
snapshot(): RealtimeSupervisorSnapshot {
|
|
253
|
+
return this.#snapshot;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
subscribe(listener: () => void): () => void {
|
|
257
|
+
this.#listeners.add(listener);
|
|
258
|
+
return () => this.#listeners.delete(listener);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
start(): void {
|
|
262
|
+
if (this.#started || this.#stopped) return;
|
|
263
|
+
this.#started = true;
|
|
264
|
+
this.#unsubscribeDiagnostics = this.#client.onDiagnostics((snapshot) =>
|
|
265
|
+
this.#observeDiagnostics(snapshot),
|
|
266
|
+
);
|
|
267
|
+
this.#unsubscribeConnectivity = this.#connectivity?.subscribe(() =>
|
|
268
|
+
this.#reconcileHostState(),
|
|
269
|
+
);
|
|
270
|
+
this.#unsubscribeLifecycle = this.#lifecycle?.subscribe(() =>
|
|
271
|
+
this.#reconcileHostState(),
|
|
272
|
+
);
|
|
273
|
+
this.#unsubscribeProtection = this.#protection?.subscribe(() =>
|
|
274
|
+
this.#reconcileHostState(),
|
|
275
|
+
);
|
|
276
|
+
this.#reconcileHostState();
|
|
277
|
+
const generation = this.#generation;
|
|
278
|
+
void this.#initialize(generation);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
stop(): void {
|
|
282
|
+
if (this.#stopped) return;
|
|
283
|
+
this.#stopped = true;
|
|
284
|
+
this.#generation += 1;
|
|
285
|
+
const disconnect =
|
|
286
|
+
this.#connected || this.#transportConnected || this.#connecting;
|
|
287
|
+
this.#connected = false;
|
|
288
|
+
this.#transportConnected = false;
|
|
289
|
+
this.#connecting = false;
|
|
290
|
+
this.#attempt = 0;
|
|
291
|
+
this.#clearRetry();
|
|
292
|
+
this.#unsubscribeDiagnostics?.();
|
|
293
|
+
this.#unsubscribeConnectivity?.();
|
|
294
|
+
this.#unsubscribeLifecycle?.();
|
|
295
|
+
this.#unsubscribeProtection?.();
|
|
296
|
+
this.#publish({ phase: 'stopped', attempt: 0 });
|
|
297
|
+
if (disconnect) this.#disconnect();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async #initialize(generation: number): Promise<void> {
|
|
301
|
+
let snapshot: ClientDiagnosticsSnapshot | undefined;
|
|
302
|
+
try {
|
|
303
|
+
snapshot = await this.#client.diagnosticsSnapshot();
|
|
304
|
+
} catch {
|
|
305
|
+
// Protected or temporarily unavailable diagnostics must not become a
|
|
306
|
+
// startup dependency. Explicit host gates and the connect result remain.
|
|
307
|
+
}
|
|
308
|
+
if (generation !== this.#generation || this.#stopped) return;
|
|
309
|
+
this.#initialized = true;
|
|
310
|
+
if (snapshot !== undefined) this.#observeDiagnostics(snapshot, true);
|
|
311
|
+
this.#reconcileHostState();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#hostBlock(): RealtimeSuspendedPhase | undefined {
|
|
315
|
+
const protection = this.#protection?.current();
|
|
316
|
+
if (
|
|
317
|
+
this.#diagnosticSecurity === 'preflight' ||
|
|
318
|
+
protection === 'preflight' ||
|
|
319
|
+
(this.#protection !== undefined && protection !== 'active')
|
|
320
|
+
) {
|
|
321
|
+
return 'protected';
|
|
322
|
+
}
|
|
323
|
+
if (
|
|
324
|
+
this.#diagnosticConnectivity === 'offline' ||
|
|
325
|
+
this.#connectivity?.current() === 'offline'
|
|
326
|
+
) {
|
|
327
|
+
return 'offline';
|
|
328
|
+
}
|
|
329
|
+
if (this.#lifecycle?.current() === 'background') return 'background';
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#canConnect(): boolean {
|
|
334
|
+
return (
|
|
335
|
+
this.#initialized &&
|
|
336
|
+
!this.#stopped &&
|
|
337
|
+
this.#realtimeSupported &&
|
|
338
|
+
!this.#hostBlock()
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#publish(snapshot: RealtimeSupervisorSnapshot): void {
|
|
343
|
+
if (
|
|
344
|
+
this.#snapshot.phase === snapshot.phase &&
|
|
345
|
+
this.#snapshot.attempt === snapshot.attempt &&
|
|
346
|
+
this.#snapshot.retryDelayMs === snapshot.retryDelayMs
|
|
347
|
+
) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
this.#snapshot = Object.freeze(snapshot);
|
|
351
|
+
for (const listener of this.#listeners) {
|
|
352
|
+
try {
|
|
353
|
+
listener();
|
|
354
|
+
} catch {
|
|
355
|
+
// Observers cannot alter transport ownership or retry policy.
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#clearRetry(): void {
|
|
361
|
+
this.#cancelRetry?.();
|
|
362
|
+
this.#cancelRetry = undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#schedule(delayMs: number): void {
|
|
366
|
+
if (
|
|
367
|
+
!this.#canConnect() ||
|
|
368
|
+
this.#connected ||
|
|
369
|
+
this.#connecting ||
|
|
370
|
+
this.#cancelRetry
|
|
371
|
+
) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
this.#publish(
|
|
375
|
+
delayMs > 0
|
|
376
|
+
? { phase: 'retrying', attempt: this.#attempt, retryDelayMs: delayMs }
|
|
377
|
+
: { phase: 'connecting', attempt: this.#attempt },
|
|
378
|
+
);
|
|
379
|
+
this.#cancelRetry = this.#scheduleTimer(() => {
|
|
380
|
+
this.#cancelRetry = undefined;
|
|
381
|
+
void this.#connect();
|
|
382
|
+
}, delayMs);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#retryDelay(): number {
|
|
386
|
+
const exponential = Math.min(
|
|
387
|
+
this.#maximumDelayMs,
|
|
388
|
+
this.#initialDelayMs * 2 ** this.#attempt,
|
|
389
|
+
);
|
|
390
|
+
const random = this.#random();
|
|
391
|
+
const boundedRandom = Number.isFinite(random)
|
|
392
|
+
? Math.max(0, Math.min(1, random))
|
|
393
|
+
: 0;
|
|
394
|
+
const jitter = Math.floor(exponential * 0.25 * boundedRandom);
|
|
395
|
+
return Math.min(this.#maximumDelayMs, exponential + jitter);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#scheduleRetry(): void {
|
|
399
|
+
if (!this.#canConnect()) {
|
|
400
|
+
this.#reconcileHostState();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const delay = this.#retryDelay();
|
|
404
|
+
this.#attempt = Math.min(32, this.#attempt + 1);
|
|
405
|
+
this.#schedule(delay);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async #connect(): Promise<void> {
|
|
409
|
+
if (!this.#canConnect() || this.#connected || this.#connecting) return;
|
|
410
|
+
this.#connecting = true;
|
|
411
|
+
this.#publish({ phase: 'connecting', attempt: this.#attempt });
|
|
412
|
+
const generation = this.#generation;
|
|
413
|
+
let failed = false;
|
|
414
|
+
try {
|
|
415
|
+
await this.#client.connectRealtime();
|
|
416
|
+
this.#transportConnected = true;
|
|
417
|
+
if (generation !== this.#generation || !this.#canConnect()) {
|
|
418
|
+
this.#disconnect();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
await this.#catchUpConnectedTransport(generation);
|
|
422
|
+
} catch {
|
|
423
|
+
failed = true;
|
|
424
|
+
this.#connected = false;
|
|
425
|
+
this.#transportConnected = false;
|
|
426
|
+
this.#disconnect();
|
|
427
|
+
} finally {
|
|
428
|
+
this.#connecting = false;
|
|
429
|
+
}
|
|
430
|
+
if (failed && generation === this.#generation) this.#scheduleRetry();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async #catchUpConnectedTransport(generation: number): Promise<void> {
|
|
434
|
+
await this.#client.syncUntilIdle();
|
|
435
|
+
if (
|
|
436
|
+
generation !== this.#generation ||
|
|
437
|
+
!this.#canConnect() ||
|
|
438
|
+
!this.#transportConnected
|
|
439
|
+
) {
|
|
440
|
+
this.#disconnect();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
this.#connected = true;
|
|
444
|
+
this.#attempt = 0;
|
|
445
|
+
this.#publish({ phase: 'connected', attempt: 0 });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async #adoptConnectedTransport(): Promise<void> {
|
|
449
|
+
if (!this.#canConnect() || this.#connecting || !this.#transportConnected) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.#connecting = true;
|
|
453
|
+
this.#clearRetry();
|
|
454
|
+
this.#publish({ phase: 'connecting', attempt: this.#attempt });
|
|
455
|
+
const generation = this.#generation;
|
|
456
|
+
let failed = false;
|
|
457
|
+
try {
|
|
458
|
+
await this.#catchUpConnectedTransport(generation);
|
|
459
|
+
} catch {
|
|
460
|
+
failed = true;
|
|
461
|
+
this.#connected = false;
|
|
462
|
+
this.#transportConnected = false;
|
|
463
|
+
this.#disconnect();
|
|
464
|
+
} finally {
|
|
465
|
+
this.#connecting = false;
|
|
466
|
+
}
|
|
467
|
+
if (failed && generation === this.#generation) this.#scheduleRetry();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
#observeDiagnostics(
|
|
471
|
+
snapshot: ClientDiagnosticsSnapshot,
|
|
472
|
+
initial = false,
|
|
473
|
+
): void {
|
|
474
|
+
if (this.#stopped) return;
|
|
475
|
+
this.#diagnosticConnectivity = snapshot.host.connectivity;
|
|
476
|
+
this.#diagnosticSecurity = snapshot.securityLifecycle;
|
|
477
|
+
if (snapshot.host.realtime === 'unsupported') {
|
|
478
|
+
const disconnect =
|
|
479
|
+
this.#connected || this.#transportConnected || this.#connecting;
|
|
480
|
+
this.#realtimeSupported = false;
|
|
481
|
+
this.#generation += 1;
|
|
482
|
+
this.#connected = false;
|
|
483
|
+
this.#transportConnected = false;
|
|
484
|
+
this.#attempt = 0;
|
|
485
|
+
this.#clearRetry();
|
|
486
|
+
this.#publish({ phase: 'unsupported', attempt: 0 });
|
|
487
|
+
if (disconnect) this.#disconnect();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (!this.#realtimeSupported) return;
|
|
491
|
+
this.#realtimeSupported = true;
|
|
492
|
+
const block = this.#hostBlock();
|
|
493
|
+
if (block) {
|
|
494
|
+
this.#suspend(block);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (snapshot.host.realtime === 'connected') {
|
|
498
|
+
this.#transportConnected = true;
|
|
499
|
+
if (this.#connecting) return;
|
|
500
|
+
this.#connected = false;
|
|
501
|
+
void this.#adoptConnectedTransport();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (snapshot.host.realtime === 'disconnected') {
|
|
505
|
+
this.#transportConnected = false;
|
|
506
|
+
this.#connected = false;
|
|
507
|
+
if (this.#connecting) return;
|
|
508
|
+
if (this.#initialized && !initial) this.#scheduleRetry();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
this.#reconcileHostState();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
#reconcileHostState(): void {
|
|
515
|
+
if (this.#stopped) return;
|
|
516
|
+
const block = this.#hostBlock();
|
|
517
|
+
if (block) {
|
|
518
|
+
this.#suspend(block);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (!this.#initialized) {
|
|
522
|
+
this.#publish({ phase: 'idle', attempt: 0 });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (!this.#realtimeSupported) {
|
|
526
|
+
this.#publish({ phase: 'unsupported', attempt: 0 });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (this.#connected) {
|
|
530
|
+
this.#publish({ phase: 'connected', attempt: 0 });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
this.#schedule(0);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#suspend(phase: RealtimeSuspendedPhase): void {
|
|
537
|
+
const disconnect =
|
|
538
|
+
this.#connected || this.#transportConnected || this.#connecting;
|
|
539
|
+
const hadScheduledWork = this.#cancelRetry !== undefined;
|
|
540
|
+
const phaseChanged = this.#snapshot.phase !== phase;
|
|
541
|
+
if (!disconnect && !hadScheduledWork && !phaseChanged) return;
|
|
542
|
+
this.#generation += 1;
|
|
543
|
+
this.#connected = false;
|
|
544
|
+
this.#transportConnected = false;
|
|
545
|
+
this.#connecting = false;
|
|
546
|
+
this.#attempt = 0;
|
|
547
|
+
this.#clearRetry();
|
|
548
|
+
this.#publish({ phase, attempt: 0 });
|
|
549
|
+
if (disconnect) this.#disconnect();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
#disconnect(): void {
|
|
553
|
+
void Promise.resolve(this.#client.disconnectRealtime()).catch(
|
|
554
|
+
() => undefined,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Install one supervisor and make client disposal cancel it before close. */
|
|
560
|
+
export function installRealtimeSupervisor<T extends RealtimeSupervisorClient>(
|
|
561
|
+
client: T,
|
|
562
|
+
options?: RealtimeSupervisorOptions,
|
|
563
|
+
): T {
|
|
564
|
+
if (attachment(client)) return client;
|
|
565
|
+
const supervisor = new RealtimeSupervisor(client, options);
|
|
566
|
+
Object.defineProperty(client, REALTIME_SUPERVISOR_KEY, {
|
|
567
|
+
configurable: false,
|
|
568
|
+
enumerable: false,
|
|
569
|
+
writable: false,
|
|
570
|
+
value: { version: 1, supervisor } satisfies RealtimeSupervisorAttachment,
|
|
571
|
+
});
|
|
572
|
+
const close = client.close.bind(client);
|
|
573
|
+
Object.defineProperty(client, 'close', {
|
|
574
|
+
configurable: true,
|
|
575
|
+
writable: true,
|
|
576
|
+
value: async () => {
|
|
577
|
+
supervisor.stop();
|
|
578
|
+
await close();
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
supervisor.start();
|
|
582
|
+
return client;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function realtimeSupervisorSnapshot(
|
|
586
|
+
client: object,
|
|
587
|
+
): RealtimeSupervisorSnapshot {
|
|
588
|
+
return attachment(client)?.supervisor.snapshot() ?? UNSUPPORTED_SNAPSHOT;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export function subscribeRealtimeSupervisor(
|
|
592
|
+
client: object,
|
|
593
|
+
listener: () => void,
|
|
594
|
+
): () => void {
|
|
595
|
+
return (
|
|
596
|
+
attachment(client)?.supervisor.subscribe(listener) ?? (() => undefined)
|
|
597
|
+
);
|
|
598
|
+
}
|