@syncular/client 0.25.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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/reactive-store.js +32 -17
- 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 +20 -1
- package/dist/worker-host.js +17 -2
- 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/reactive-store.ts +37 -17
- package/src/realtime-supervisor.ts +16 -2
- package/src/worker-entry.ts +18 -4
- package/src/worker-host.ts +34 -2
- package/src/worker-protocol.ts +14 -1
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).
|
|
@@ -245,6 +256,14 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
245
256
|
readonly name?: string;
|
|
246
257
|
}): Promise<BlobRef>;
|
|
247
258
|
fetchBlob(blobIdOrRef: string): Promise<CachedBlob>;
|
|
259
|
+
/**
|
|
260
|
+
* Replace the full host auth header set. The next HTTP request uses it;
|
|
261
|
+
* a live realtime socket keeps its handshake credentials. A follower
|
|
262
|
+
* forwards to the leader and keeps the set for its own promotion.
|
|
263
|
+
*/
|
|
264
|
+
setHeaders(headers: Readonly<Record<string, string>>): Promise<void>;
|
|
265
|
+
/** @internal — the latest `setHeaders` set, carried into a promotion. */
|
|
266
|
+
__headers: Readonly<Record<string, string>> | undefined;
|
|
248
267
|
/** Sever/restore transport + realtime inside the worker (demos). */
|
|
249
268
|
setOffline(offline: boolean): Promise<void>;
|
|
250
269
|
/** 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
|
: {}),
|
|
@@ -691,7 +706,7 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
691
706
|
try {
|
|
692
707
|
const core = await startWorkerCore({
|
|
693
708
|
config,
|
|
694
|
-
initConfig: buildInitConfig(config),
|
|
709
|
+
initConfig: buildInitConfig(config, handle.__headers ?? config.headers),
|
|
695
710
|
lease,
|
|
696
711
|
dispatchEvent: (event) => {
|
|
697
712
|
fireConfigCallbacks(config, event);
|
|
@@ -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.0",
|
|
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.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.26.0",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -82,9 +82,16 @@ import {
|
|
|
82
82
|
MAX_DIAGNOSTIC_DOMAINS,
|
|
83
83
|
MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
84
84
|
MAX_DIAGNOSTIC_QUERY_FAILURES,
|
|
85
|
+
type RealtimePolicy,
|
|
86
|
+
type RealtimeState,
|
|
85
87
|
} from './diagnostics';
|
|
86
88
|
import type { EncryptionConfig } from './encryption';
|
|
87
|
-
import {
|
|
89
|
+
import {
|
|
90
|
+
ClientSyncError,
|
|
91
|
+
classifySqliteFailure,
|
|
92
|
+
REALTIME_LOST_CODE,
|
|
93
|
+
RealtimeUnavailableError,
|
|
94
|
+
} from './errors';
|
|
88
95
|
import {
|
|
89
96
|
ChangeAccumulator,
|
|
90
97
|
ChangeEmitter,
|
|
@@ -344,6 +351,15 @@ export interface SyncClientConfig {
|
|
|
344
351
|
*/
|
|
345
352
|
readonly blobCacheMaxBytes?: number;
|
|
346
353
|
readonly realtime?: RealtimeConnector;
|
|
354
|
+
/**
|
|
355
|
+
* SPEC §8.8: how sync rounds treat the realtime binding. `optional`
|
|
356
|
+
* (default) rides a connected socket and otherwise uses `POST /sync`.
|
|
357
|
+
* `required` refuses a round with `RealtimeUnavailableError` while the
|
|
358
|
+
* socket is not connected and never falls back to HTTP. `off` never uses
|
|
359
|
+
* realtime; an explicit `connectRealtime` fails with `sync.invalid_request`.
|
|
360
|
+
* `required` without a `realtime` connector is refused at construction.
|
|
361
|
+
*/
|
|
362
|
+
readonly realtimePolicy?: RealtimePolicy;
|
|
347
363
|
/** Stable per-device id (§1.5); defaults to a persisted random UUID. */
|
|
348
364
|
readonly clientId?: string;
|
|
349
365
|
readonly leaderLock?: LeaderLock;
|
|
@@ -672,6 +688,12 @@ function isFinalPushResult(frame: PushResultFrame): boolean {
|
|
|
672
688
|
);
|
|
673
689
|
}
|
|
674
690
|
|
|
691
|
+
/** The stable `code` of a thrown realtime transport error, when it has one. */
|
|
692
|
+
function realtimeErrorCode(error: unknown): string | undefined {
|
|
693
|
+
const code = (error as { code?: unknown } | undefined)?.code;
|
|
694
|
+
return typeof code === 'string' ? code : undefined;
|
|
695
|
+
}
|
|
696
|
+
|
|
675
697
|
/** Canonical client reads, shared by synchronous cores and promise hosts. */
|
|
676
698
|
export type ClientSnapshotMethods = Pick<
|
|
677
699
|
SyncClient,
|
|
@@ -725,6 +747,11 @@ export class SyncClient {
|
|
|
725
747
|
#conflicts: ConflictRecord[] = [];
|
|
726
748
|
#rejections: RejectionRecord[] = [];
|
|
727
749
|
#socket: RealtimeSocket | undefined;
|
|
750
|
+
/** SPEC §8.8: the explicit realtime availability state and its evidence. */
|
|
751
|
+
readonly #realtimePolicy: RealtimePolicy;
|
|
752
|
+
#realtimeState: RealtimeState;
|
|
753
|
+
#realtimeReasonCode: string | undefined;
|
|
754
|
+
#realtimeRetryDelayMs: number | undefined;
|
|
728
755
|
#realtimeConnectPromise: Promise<void> | undefined;
|
|
729
756
|
#realtimeGeneration = 0;
|
|
730
757
|
#pendingRound: PendingRound | undefined;
|
|
@@ -802,9 +829,33 @@ export class SyncClient {
|
|
|
802
829
|
'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
|
|
803
830
|
);
|
|
804
831
|
}
|
|
805
|
-
this.#config = config;
|
|
806
832
|
this.#db = config.database;
|
|
807
833
|
this.#schema = compileClientSchema(config.schema);
|
|
834
|
+
const realtimePolicy = config.realtimePolicy ?? 'optional';
|
|
835
|
+
if (
|
|
836
|
+
realtimePolicy !== 'required' &&
|
|
837
|
+
realtimePolicy !== 'optional' &&
|
|
838
|
+
realtimePolicy !== 'off'
|
|
839
|
+
) {
|
|
840
|
+
throw new ClientSyncError(
|
|
841
|
+
'sync.invalid_request',
|
|
842
|
+
"realtimePolicy must be 'required', 'optional', or 'off'",
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
if (realtimePolicy === 'required' && config.realtime === undefined) {
|
|
846
|
+
throw new ClientSyncError(
|
|
847
|
+
'sync.invalid_request',
|
|
848
|
+
"realtimePolicy 'required' needs a realtime connector",
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
this.#config = config;
|
|
852
|
+
this.#realtimePolicy = realtimePolicy;
|
|
853
|
+
this.#realtimeState =
|
|
854
|
+
realtimePolicy === 'off'
|
|
855
|
+
? 'disabled'
|
|
856
|
+
: config.realtime === undefined
|
|
857
|
+
? 'unsupported'
|
|
858
|
+
: 'disconnected';
|
|
808
859
|
this.#encryption = config.encryption;
|
|
809
860
|
this.#securityLifecycle =
|
|
810
861
|
config.securityPreflight === true ? 'preflight' : 'active';
|
|
@@ -1751,12 +1802,14 @@ export class SyncClient {
|
|
|
1751
1802
|
kind: 'direct',
|
|
1752
1803
|
role: 'single',
|
|
1753
1804
|
connectivity,
|
|
1754
|
-
realtime:
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1805
|
+
realtime: this.#realtimeState,
|
|
1806
|
+
realtimePolicy: this.#realtimePolicy,
|
|
1807
|
+
...(this.#realtimeReasonCode !== undefined
|
|
1808
|
+
? { realtimeReasonCode: this.#realtimeReasonCode }
|
|
1809
|
+
: {}),
|
|
1810
|
+
...(this.#realtimeRetryDelayMs !== undefined
|
|
1811
|
+
? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
|
|
1812
|
+
: {}),
|
|
1760
1813
|
},
|
|
1761
1814
|
securityLifecycle: this.#securityLifecycle,
|
|
1762
1815
|
schema: {
|
|
@@ -3532,9 +3585,20 @@ export class SyncClient {
|
|
|
3532
3585
|
explicitlyRetryable === true ||
|
|
3533
3586
|
(explicitlyRetryable === undefined && typeof code !== 'string');
|
|
3534
3587
|
if (retryable) {
|
|
3588
|
+
const realtimeUnavailable = error instanceof RealtimeUnavailableError;
|
|
3589
|
+
const delay = realtimeUnavailable
|
|
3590
|
+
? error.retryDelayMs
|
|
3591
|
+
: this.#retryDelayMs;
|
|
3592
|
+
if (
|
|
3593
|
+
realtimeUnavailable ||
|
|
3594
|
+
this.#realtimeState === 'lost' ||
|
|
3595
|
+
this.#realtimeState === 'refused'
|
|
3596
|
+
) {
|
|
3597
|
+
this.#realtimeRetryDelayMs = delay;
|
|
3598
|
+
}
|
|
3535
3599
|
const intent: SyncIntent = {
|
|
3536
3600
|
kind: 'background',
|
|
3537
|
-
delayMs:
|
|
3601
|
+
delayMs: delay,
|
|
3538
3602
|
};
|
|
3539
3603
|
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
|
|
3540
3604
|
this.#emitSyncIntent(intent);
|
|
@@ -3593,50 +3657,60 @@ export class SyncClient {
|
|
|
3593
3657
|
* is connected (the socket IS the sync-round
|
|
3594
3658
|
* transport, not a fallback pair), otherwise through the configured
|
|
3595
3659
|
* `SyncTransport` seam (loopback/conformance hosts, HTTP-only
|
|
3596
|
-
* producers).
|
|
3660
|
+
* producers). Under `realtimePolicy: 'required'` an unconnected socket
|
|
3661
|
+
* refuses the round with `RealtimeUnavailableError` instead of using the
|
|
3662
|
+
* seam (SPEC §8.8).
|
|
3597
3663
|
*/
|
|
3598
3664
|
#roundTrip(request: Uint8Array): Promise<Uint8Array> {
|
|
3599
3665
|
const socket = this.#socket;
|
|
3600
|
-
if (socket
|
|
3601
|
-
return Promise
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3666
|
+
if (socket !== undefined && this.#realtimePolicy !== 'off') {
|
|
3667
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
3668
|
+
// sync() already enforces one round in flight (§8.7).
|
|
3669
|
+
this.#pendingRound = {
|
|
3670
|
+
scanner: new MessageStreamScanner(),
|
|
3671
|
+
resolve,
|
|
3672
|
+
reject,
|
|
3673
|
+
};
|
|
3674
|
+
const tagged = new Uint8Array(request.length + 1);
|
|
3675
|
+
tagged[0] = REALTIME_TAG_ROUND;
|
|
3676
|
+
tagged.set(request, 1);
|
|
3677
|
+
try {
|
|
3678
|
+
socket.sendBytes(tagged);
|
|
3679
|
+
} catch (error) {
|
|
3680
|
+
this.#pendingRound = undefined;
|
|
3681
|
+
// The socket could not carry the round: record the same loss the
|
|
3682
|
+
// Rust core records when its realtime round call fails (§8.8).
|
|
3683
|
+
this.#setRealtimeState('lost', 'sync.transport_failed');
|
|
3684
|
+
reject(
|
|
3685
|
+
new ClientSyncError(
|
|
3686
|
+
'sync.transport_failed',
|
|
3687
|
+
`socket round send failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3688
|
+
true,
|
|
3689
|
+
),
|
|
3614
3690
|
);
|
|
3615
|
-
}
|
|
3691
|
+
}
|
|
3692
|
+
});
|
|
3616
3693
|
}
|
|
3617
|
-
|
|
3618
|
-
//
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
true,
|
|
3636
|
-
),
|
|
3694
|
+
if (this.#realtimePolicy === 'required') {
|
|
3695
|
+
// §8.8: the designated realtime path is down. Refuse the round; the
|
|
3696
|
+
// HTTP binding is not an alternative for a `required` client.
|
|
3697
|
+
throw this.#realtimeUnavailableError();
|
|
3698
|
+
}
|
|
3699
|
+
return Promise.resolve()
|
|
3700
|
+
.then(() => this.#config.transport(request))
|
|
3701
|
+
.catch((error: unknown) => {
|
|
3702
|
+
if (
|
|
3703
|
+
error instanceof ClientSyncError ||
|
|
3704
|
+
typeof (error as { code?: unknown })?.code === 'string'
|
|
3705
|
+
) {
|
|
3706
|
+
throw error;
|
|
3707
|
+
}
|
|
3708
|
+
throw new ClientSyncError(
|
|
3709
|
+
'sync.transport_failed',
|
|
3710
|
+
`transport round failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3711
|
+
true,
|
|
3637
3712
|
);
|
|
3638
|
-
}
|
|
3639
|
-
});
|
|
3713
|
+
});
|
|
3640
3714
|
}
|
|
3641
3715
|
|
|
3642
3716
|
/** Abort the in-flight socket round (socket closed or disconnected). */
|
|
@@ -3649,13 +3723,51 @@ export class SyncClient {
|
|
|
3649
3723
|
|
|
3650
3724
|
// -- realtime (§8 client side) ----------------------------------------------
|
|
3651
3725
|
|
|
3726
|
+
/**
|
|
3727
|
+
* SPEC §8.8: record an availability transition and the evidence behind it.
|
|
3728
|
+
* The reason and retry delay belong to the state that is no longer
|
|
3729
|
+
* connected; `connected` and a policy-restricted state clear them.
|
|
3730
|
+
*/
|
|
3731
|
+
#setRealtimeState(state: RealtimeState, reasonCode?: string): void {
|
|
3732
|
+
this.#realtimeState = state;
|
|
3733
|
+
this.#realtimeReasonCode =
|
|
3734
|
+
reasonCode === undefined ? undefined : this.#diagnosticCode(reasonCode);
|
|
3735
|
+
if (
|
|
3736
|
+
state === 'connected' ||
|
|
3737
|
+
state === 'disabled' ||
|
|
3738
|
+
state === 'unsupported'
|
|
3739
|
+
) {
|
|
3740
|
+
this.#realtimeRetryDelayMs = undefined;
|
|
3741
|
+
}
|
|
3742
|
+
this.#emitDiagnostics();
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
#realtimeUnavailableError(): RealtimeUnavailableError {
|
|
3746
|
+
this.#realtimeRetryDelayMs = this.#retryDelayMs;
|
|
3747
|
+
return new RealtimeUnavailableError(
|
|
3748
|
+
this.#realtimeState,
|
|
3749
|
+
this.#realtimeReasonCode,
|
|
3750
|
+
this.#retryDelayMs,
|
|
3751
|
+
);
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3652
3754
|
connectRealtime(): Promise<void> {
|
|
3653
3755
|
this.#requireActive();
|
|
3756
|
+
if (this.#realtimePolicy === 'off') {
|
|
3757
|
+
return Promise.reject(
|
|
3758
|
+
new ClientSyncError(
|
|
3759
|
+
'sync.invalid_request',
|
|
3760
|
+
"realtimePolicy is 'off'; realtime connect is refused",
|
|
3761
|
+
),
|
|
3762
|
+
);
|
|
3763
|
+
}
|
|
3654
3764
|
if (this.#socket !== undefined) return Promise.resolve();
|
|
3655
3765
|
if (this.#realtimeConnectPromise !== undefined) {
|
|
3656
3766
|
return this.#realtimeConnectPromise;
|
|
3657
3767
|
}
|
|
3658
3768
|
const generation = this.#realtimeGeneration;
|
|
3769
|
+
// The attempt is visible synchronously; runners of the task share it.
|
|
3770
|
+
this.#setRealtimeState('connecting');
|
|
3659
3771
|
const task = this.#runProtectedAsync(() =>
|
|
3660
3772
|
this.#connectRealtime(generation),
|
|
3661
3773
|
);
|
|
@@ -3684,40 +3796,53 @@ export class SyncClient {
|
|
|
3684
3796
|
);
|
|
3685
3797
|
}
|
|
3686
3798
|
let openedSocket: RealtimeSocket | undefined;
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
)
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
)
|
|
3799
|
+
try {
|
|
3800
|
+
const socket = await connector({
|
|
3801
|
+
onText: (text) => {
|
|
3802
|
+
if (generation !== this.#realtimeGeneration || !this.#started) return;
|
|
3803
|
+
this.#handleRealtimeText(text);
|
|
3804
|
+
},
|
|
3805
|
+
onBinary: (bytes) => {
|
|
3806
|
+
if (generation !== this.#realtimeGeneration || !this.#started) return;
|
|
3807
|
+
this.#routeRealtimeBinary(bytes);
|
|
3808
|
+
},
|
|
3809
|
+
onClose: () => {
|
|
3810
|
+
if (openedSocket === undefined || this.#socket !== openedSocket)
|
|
3811
|
+
return;
|
|
3812
|
+
this.#socket = undefined;
|
|
3813
|
+
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
3814
|
+
// §8.8: an undesired close is a visible lost state, not a silent
|
|
3815
|
+
// downgrade to HTTP rounds.
|
|
3816
|
+
this.#setRealtimeState('lost', REALTIME_LOST_CODE);
|
|
3817
|
+
this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
|
|
3818
|
+
this.#emitDiagnostics();
|
|
3819
|
+
},
|
|
3820
|
+
});
|
|
3821
|
+
openedSocket = socket;
|
|
3822
|
+
if (this.#securityLifecycle === 'preflight') {
|
|
3823
|
+
socket.close();
|
|
3824
|
+
throw new ClientSyncError(
|
|
3825
|
+
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
3826
|
+
'realtime connected after the client entered security preflight',
|
|
3827
|
+
);
|
|
3828
|
+
}
|
|
3829
|
+
if (generation !== this.#realtimeGeneration || !this.#started) {
|
|
3830
|
+
socket.close();
|
|
3831
|
+
throw new ClientSyncError(
|
|
3832
|
+
'client.realtime_cancelled',
|
|
3833
|
+
'realtime connection was cancelled before activation',
|
|
3834
|
+
);
|
|
3835
|
+
}
|
|
3836
|
+
this.#socket = socket;
|
|
3837
|
+
this.#setRealtimeState('connected');
|
|
3838
|
+
} catch (error) {
|
|
3839
|
+
// A superseded attempt leaves the successor's (or the deliberate
|
|
3840
|
+
// disconnect's) state alone.
|
|
3841
|
+
if (generation === this.#realtimeGeneration) {
|
|
3842
|
+
this.#setRealtimeState('refused', realtimeErrorCode(error));
|
|
3843
|
+
}
|
|
3844
|
+
throw error;
|
|
3718
3845
|
}
|
|
3719
|
-
this.#socket = socket;
|
|
3720
|
-
this.#emitDiagnostics();
|
|
3721
3846
|
}
|
|
3722
3847
|
|
|
3723
3848
|
disconnectRealtime(): void {
|
|
@@ -3727,7 +3852,13 @@ export class SyncClient {
|
|
|
3727
3852
|
this.#socket = undefined;
|
|
3728
3853
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
3729
3854
|
this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
|
|
3730
|
-
this.#
|
|
3855
|
+
this.#setRealtimeState(
|
|
3856
|
+
this.#realtimePolicy === 'off'
|
|
3857
|
+
? 'disabled'
|
|
3858
|
+
: this.#config.realtime === undefined
|
|
3859
|
+
? 'unsupported'
|
|
3860
|
+
: 'disconnected',
|
|
3861
|
+
);
|
|
3731
3862
|
}
|
|
3732
3863
|
|
|
3733
3864
|
/**
|
package/src/diagnostics.ts
CHANGED
|
@@ -23,17 +23,32 @@ export type ClientDiagnosticsHostRole =
|
|
|
23
23
|
| 'follower'
|
|
24
24
|
| 'unknown';
|
|
25
25
|
export type ClientDiagnosticsConnectivity = 'online' | 'offline' | 'unknown';
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* SPEC §8.8: how a client treats the realtime binding for sync rounds. The
|
|
28
|
+
* default is `optional`.
|
|
29
|
+
*/
|
|
30
|
+
export type RealtimePolicy = 'required' | 'optional' | 'off';
|
|
31
|
+
export type RealtimeState =
|
|
27
32
|
| 'connected'
|
|
33
|
+
| 'connecting'
|
|
28
34
|
| 'disconnected'
|
|
35
|
+
| 'lost'
|
|
36
|
+
| 'refused'
|
|
29
37
|
| 'unsupported'
|
|
38
|
+
| 'disabled'
|
|
30
39
|
| 'unknown';
|
|
31
40
|
|
|
32
41
|
export interface ClientDiagnosticsHost {
|
|
33
42
|
readonly kind: ClientDiagnosticsHostKind;
|
|
34
43
|
readonly role: ClientDiagnosticsHostRole;
|
|
35
44
|
readonly connectivity: ClientDiagnosticsConnectivity;
|
|
36
|
-
readonly realtime:
|
|
45
|
+
readonly realtime: RealtimeState;
|
|
46
|
+
/** Present on core-emitted snapshots; host wrappers may forward only `realtime`. */
|
|
47
|
+
readonly realtimePolicy?: RealtimePolicy;
|
|
48
|
+
/** Stable code behind a `lost` or `refused` state (SPEC §8.8). */
|
|
49
|
+
readonly realtimeReasonCode?: string;
|
|
50
|
+
/** Next retry delay in ms while the socket is not connected. */
|
|
51
|
+
readonly realtimeRetryDelayMs?: number;
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
export interface ExpectedDiagnosticSubscription {
|