@syncular/client 0.25.2 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 { ClientSyncError, classifySqliteFailure } from './errors';
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
- this.#config.realtime === undefined
1756
- ? 'unsupported'
1757
- : this.#socket === undefined
1758
- ? 'disconnected'
1759
- : 'connected',
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: this.#retryDelayMs,
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 === undefined) {
3601
- return Promise.resolve()
3602
- .then(() => this.#config.transport(request))
3603
- .catch((error: unknown) => {
3604
- if (
3605
- error instanceof ClientSyncError ||
3606
- typeof (error as { code?: unknown })?.code === 'string'
3607
- ) {
3608
- throw error;
3609
- }
3610
- throw new ClientSyncError(
3611
- 'sync.transport_failed',
3612
- `transport round failed: ${error instanceof Error ? error.message : String(error)}`,
3613
- true,
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
- return new Promise<Uint8Array>((resolve, reject) => {
3618
- // sync() already enforces one round in flight (§8.7).
3619
- this.#pendingRound = {
3620
- scanner: new MessageStreamScanner(),
3621
- resolve,
3622
- reject,
3623
- };
3624
- const tagged = new Uint8Array(request.length + 1);
3625
- tagged[0] = REALTIME_TAG_ROUND;
3626
- tagged.set(request, 1);
3627
- try {
3628
- socket.sendBytes(tagged);
3629
- } catch (error) {
3630
- this.#pendingRound = undefined;
3631
- reject(
3632
- new ClientSyncError(
3633
- 'sync.transport_failed',
3634
- `socket round send failed: ${error instanceof Error ? error.message : String(error)}`,
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
- const socket = await connector({
3688
- onText: (text) => {
3689
- if (generation !== this.#realtimeGeneration || !this.#started) return;
3690
- this.#handleRealtimeText(text);
3691
- },
3692
- onBinary: (bytes) => {
3693
- if (generation !== this.#realtimeGeneration || !this.#started) return;
3694
- this.#routeRealtimeBinary(bytes);
3695
- },
3696
- onClose: () => {
3697
- if (openedSocket === undefined || this.#socket !== openedSocket) return;
3698
- this.#socket = undefined;
3699
- this.#presence.clear(); // §8.6.1: presence is per-connection
3700
- this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
3701
- this.#emitDiagnostics();
3702
- },
3703
- });
3704
- openedSocket = socket;
3705
- if (this.#securityLifecycle === 'preflight') {
3706
- socket.close();
3707
- throw new ClientSyncError(
3708
- SECURITY_PREFLIGHT_REQUIRED_CODE,
3709
- 'realtime connected after the client entered security preflight',
3710
- );
3711
- }
3712
- if (generation !== this.#realtimeGeneration || !this.#started) {
3713
- socket.close();
3714
- throw new ClientSyncError(
3715
- 'client.realtime_cancelled',
3716
- 'realtime connection was cancelled before activation',
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.#emitDiagnostics();
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
  /**
@@ -23,17 +23,32 @@ export type ClientDiagnosticsHostRole =
23
23
  | 'follower'
24
24
  | 'unknown';
25
25
  export type ClientDiagnosticsConnectivity = 'online' | 'offline' | 'unknown';
26
- export type ClientDiagnosticsRealtime =
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: ClientDiagnosticsRealtime;
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 {
package/src/errors.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { RealtimeState } from './diagnostics';
2
+
1
3
  /** A persistent local store is temporarily owned by another live engine. */
2
4
  export const STORAGE_BUSY_CODE = 'client.storage_busy';
3
5
 
@@ -10,13 +12,19 @@ export const STORAGE_CORRUPT_CODE = 'client.storage_corrupt';
10
12
  /** A local read hit SQLITE_IOERR (SPEC §7.5). */
11
13
  export const STORAGE_IO_CODE = 'client.storage_io';
12
14
 
15
+ /** SPEC §8.8: a round under `realtimePolicy: 'required'` found no socket. */
16
+ export const REALTIME_UNAVAILABLE_CODE = 'sync.realtime_unavailable';
17
+
18
+ /** SPEC §8.8: a connected socket ended without a deliberate disconnect. */
19
+ export const REALTIME_LOST_CODE = 'client.realtime_lost';
20
+
13
21
  /**
14
22
  * Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
15
23
  * unchanged. Host/runtime-only conditions may use the separate `client.*`
16
24
  * namespace and never travel on the wire.
17
25
  */
18
26
  export class ClientSyncError extends Error {
19
- override readonly name = 'ClientSyncError';
27
+ override readonly name: string = 'ClientSyncError';
20
28
  readonly code: string;
21
29
  readonly retryable: boolean;
22
30
 
@@ -27,6 +35,35 @@ export class ClientSyncError extends Error {
27
35
  }
28
36
  }
29
37
 
38
+ /**
39
+ * SPEC §8.8: the `required` realtime policy refused a round because the
40
+ * socket is not connected. `state` is the availability state that refused
41
+ * (`connecting`, `lost`, `refused`, or `disconnected`), `reasonCode` is the
42
+ * stable code behind a `lost` or `refused` state, and `retryDelayMs` is the
43
+ * delay of the background retry intent the client scheduled with this throw.
44
+ */
45
+ export class RealtimeUnavailableError extends ClientSyncError {
46
+ override readonly name = 'RealtimeUnavailableError';
47
+ readonly state: RealtimeState;
48
+ readonly reasonCode: string | undefined;
49
+ readonly retryDelayMs: number;
50
+
51
+ constructor(
52
+ state: RealtimeState,
53
+ reasonCode: string | undefined,
54
+ retryDelayMs: number,
55
+ ) {
56
+ super(
57
+ REALTIME_UNAVAILABLE_CODE,
58
+ 'realtime is required and the realtime socket is not connected',
59
+ true,
60
+ );
61
+ this.state = state;
62
+ this.reasonCode = reasonCode;
63
+ this.retryDelayMs = retryDelayMs;
64
+ }
65
+ }
66
+
30
67
  /**
31
68
  * SPEC §7.5: classify a local SQLite read failure by the numeric result code
32
69
  * the driver exposes (bun:sqlite `errno`, node:sqlite `errcode`, sqlite-wasm
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
- readonly headers?: Readonly<Record<string, string>>;
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?.headers,
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?.headers,
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?.headers,
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?.headers,
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?.headers },
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?.headers,
301
+ ...hostHeaders(options),
288
302
  },
289
303
  body: JSON.stringify({
290
304
  byteLength,