@syncular/client 0.25.2 → 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/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/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/src/http.ts
CHANGED
|
@@ -35,10 +35,24 @@ async function throwHttpError(response: Response): Promise<never> {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export interface HttpTransportOptions {
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Host auth headers. A function is read on every request, so a rotated
|
|
40
|
+
* token applies from the next request on. Signed-URL fetches never
|
|
41
|
+
* carry them (§5.4, §5.9.5).
|
|
42
|
+
*/
|
|
43
|
+
readonly headers?:
|
|
44
|
+
| Readonly<Record<string, string>>
|
|
45
|
+
| (() => Readonly<Record<string, string>>);
|
|
39
46
|
readonly fetch?: typeof fetch;
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
function hostHeaders(
|
|
50
|
+
options: HttpTransportOptions | undefined,
|
|
51
|
+
): Readonly<Record<string, string>> | undefined {
|
|
52
|
+
const headers = options?.headers;
|
|
53
|
+
return typeof headers === 'function' ? headers() : headers;
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
/** POST `<mount>/sync` with SSP2 bodies (§1.1). */
|
|
43
57
|
export function httpSyncTransport(
|
|
44
58
|
syncUrl: string,
|
|
@@ -50,7 +64,7 @@ export function httpSyncTransport(
|
|
|
50
64
|
method: 'POST',
|
|
51
65
|
headers: {
|
|
52
66
|
'Content-Type': SSP2_CONTENT_TYPE,
|
|
53
|
-
...options
|
|
67
|
+
...hostHeaders(options),
|
|
54
68
|
},
|
|
55
69
|
body: request.slice().buffer as ArrayBuffer,
|
|
56
70
|
});
|
|
@@ -70,7 +84,7 @@ export function httpRemoteOperationTransport(
|
|
|
70
84
|
method: 'POST',
|
|
71
85
|
headers: {
|
|
72
86
|
'Content-Type': 'application/vnd.syncular.operations.v1+json',
|
|
73
|
-
...options
|
|
87
|
+
...hostHeaders(options),
|
|
74
88
|
},
|
|
75
89
|
body: request.slice().buffer as ArrayBuffer,
|
|
76
90
|
});
|
|
@@ -188,7 +202,7 @@ export function httpSegmentDownloader(
|
|
|
188
202
|
{
|
|
189
203
|
headers: {
|
|
190
204
|
'X-Syncular-Scopes': request.requestedScopesJson,
|
|
191
|
-
...options
|
|
205
|
+
...hostHeaders(options),
|
|
192
206
|
},
|
|
193
207
|
},
|
|
194
208
|
);
|
|
@@ -232,7 +246,7 @@ export function httpBlobTransport(
|
|
|
232
246
|
method: 'PUT',
|
|
233
247
|
headers: {
|
|
234
248
|
'Content-Type': mediaType ?? 'application/octet-stream',
|
|
235
|
-
...options
|
|
249
|
+
...hostHeaders(options),
|
|
236
250
|
},
|
|
237
251
|
body: bytes.slice().buffer as ArrayBuffer,
|
|
238
252
|
});
|
|
@@ -240,7 +254,7 @@ export function httpBlobTransport(
|
|
|
240
254
|
},
|
|
241
255
|
download: async (blobId) => {
|
|
242
256
|
const response = await doFetch(blobUrl(blobId), {
|
|
243
|
-
headers: { ...options
|
|
257
|
+
headers: { ...hostHeaders(options) },
|
|
244
258
|
});
|
|
245
259
|
if (!response.ok) await throwHttpError(response);
|
|
246
260
|
// §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
|
|
@@ -284,7 +298,7 @@ export function httpBlobTransport(
|
|
|
284
298
|
method: 'POST',
|
|
285
299
|
headers: {
|
|
286
300
|
'Content-Type': 'application/json',
|
|
287
|
-
...options
|
|
301
|
+
...hostHeaders(options),
|
|
288
302
|
},
|
|
289
303
|
body: JSON.stringify({
|
|
290
304
|
byteLength,
|
|
@@ -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).
|
|
@@ -689,6 +701,19 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
689
701
|
return this.#call('fetchBlob', [blobIdOrRef]);
|
|
690
702
|
}
|
|
691
703
|
|
|
704
|
+
/**
|
|
705
|
+
* Replace the full host auth header set. The next HTTP request uses it;
|
|
706
|
+
* a live realtime socket keeps its handshake credentials. A follower
|
|
707
|
+
* forwards to the leader and keeps the set for its own promotion.
|
|
708
|
+
*/
|
|
709
|
+
setHeaders(headers: Readonly<Record<string, string>>): Promise<void> {
|
|
710
|
+
this.__headers = headers;
|
|
711
|
+
return this.#call('setHeaders', [headers]);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** @internal — the latest `setHeaders` set, carried into a promotion. */
|
|
715
|
+
__headers: Readonly<Record<string, string>> | undefined;
|
|
716
|
+
|
|
692
717
|
/** Sever/restore transport + realtime inside the worker (demos). */
|
|
693
718
|
setOffline(offline: boolean): Promise<void> {
|
|
694
719
|
return this.#call('setOffline', [offline]);
|
|
@@ -839,11 +864,15 @@ async function startWorkerCore(options: {
|
|
|
839
864
|
}
|
|
840
865
|
}
|
|
841
866
|
|
|
842
|
-
function buildInitConfig(
|
|
867
|
+
function buildInitConfig(
|
|
868
|
+
config: SyncClientHandleConfig,
|
|
869
|
+
headers = config.headers,
|
|
870
|
+
): WorkerInitConfig {
|
|
843
871
|
return {
|
|
844
872
|
schema: config.schema,
|
|
845
873
|
database: config.database,
|
|
846
874
|
endpoints: config.endpoints,
|
|
875
|
+
...(headers !== undefined ? { headers } : {}),
|
|
847
876
|
...(config.encryption !== undefined
|
|
848
877
|
? { encryption: config.encryption }
|
|
849
878
|
: {}),
|
|
@@ -852,6 +881,9 @@ function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
|
|
|
852
881
|
: {}),
|
|
853
882
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
854
883
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
884
|
+
...(config.realtimePolicy !== undefined
|
|
885
|
+
? { realtimePolicy: config.realtimePolicy }
|
|
886
|
+
: {}),
|
|
855
887
|
...(config.previousVersionContext !== undefined
|
|
856
888
|
? { previousVersionContext: config.previousVersionContext }
|
|
857
889
|
: {}),
|
|
@@ -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);
|
package/src/worker-protocol.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type {
|
|
|
34
34
|
WindowState,
|
|
35
35
|
} from './client';
|
|
36
36
|
import type { SqlRow, SqlValue } from './database';
|
|
37
|
-
import type { ClientDiagnosticsSnapshot } from './diagnostics';
|
|
37
|
+
import type { ClientDiagnosticsSnapshot, RealtimePolicy } from './diagnostics';
|
|
38
38
|
import type { EncryptionKeyringConfig } from './encryption';
|
|
39
39
|
import type { ClientChangeBatch, LocalRevision } from './invalidation';
|
|
40
40
|
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge';
|
|
@@ -102,12 +102,23 @@ export interface WorkerInitConfig {
|
|
|
102
102
|
readonly schema: ClientSchema;
|
|
103
103
|
readonly database: WorkerDatabaseInit;
|
|
104
104
|
readonly endpoints: WorkerEndpoints;
|
|
105
|
+
/**
|
|
106
|
+
* Host auth headers for sync, segment, and blob requests. `setHeaders`
|
|
107
|
+
* replaces the whole set at runtime. WebSockets cannot carry headers, so
|
|
108
|
+
* realtime authenticates through `realtimeUrl` (cookie or ticket).
|
|
109
|
+
*/
|
|
110
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
105
111
|
/** Portable raw keyring installed inside the worker-owned client core. */
|
|
106
112
|
readonly encryption?: EncryptionKeyringConfig;
|
|
107
113
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
108
114
|
readonly securityPreflight?: boolean;
|
|
109
115
|
readonly clientId?: string;
|
|
110
116
|
readonly limits?: SyncClientLimits;
|
|
117
|
+
/**
|
|
118
|
+
* SPEC §8.8 realtime policy for the worker-owned core; forwarded verbatim.
|
|
119
|
+
* Absent ⇒ `optional` (a connected socket, otherwise `POST /sync`).
|
|
120
|
+
*/
|
|
121
|
+
readonly realtimePolicy?: RealtimePolicy;
|
|
111
122
|
/**
|
|
112
123
|
* RFC 0005 previous-version context, forwarded verbatim into the worker's
|
|
113
124
|
* `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
|
|
@@ -190,6 +201,8 @@ export interface WorkerApi extends Omit<
|
|
|
190
201
|
options?: { readonly mediaType?: string; readonly name?: string },
|
|
191
202
|
): Promise<BlobRef>;
|
|
192
203
|
fetchBlob(blobIdOrRef: string): Promise<CachedBlob>;
|
|
204
|
+
/** Replace the host auth header set; the next HTTP request uses it. */
|
|
205
|
+
setHeaders(headers: Readonly<Record<string, string>>): void;
|
|
193
206
|
/** Sever/restore the transport + realtime (offline simulation, demos). */
|
|
194
207
|
setOffline(offline: boolean): void;
|
|
195
208
|
close(): Promise<void>;
|