@syncular/core 0.0.6-95 → 0.1.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/blobs.d.ts +14 -7
- package/dist/blobs.d.ts.map +1 -1
- package/dist/blobs.js.map +1 -1
- package/dist/column-codecs.d.ts +3 -0
- package/dist/column-codecs.d.ts.map +1 -1
- package/dist/column-codecs.js +32 -4
- package/dist/column-codecs.js.map +1 -1
- package/dist/conflict.js.map +1 -1
- package/dist/database.js +1 -1
- package/dist/database.js.map +1 -1
- package/dist/error-responses.d.ts +393 -0
- package/dist/error-responses.d.ts.map +1 -0
- package/dist/error-responses.js +393 -0
- package/dist/error-responses.js.map +1 -0
- package/dist/http/api-client.d.ts +11 -0
- package/dist/http/api-client.d.ts.map +1 -0
- package/dist/http/api-client.js +30 -0
- package/dist/http/api-client.js.map +1 -0
- package/dist/http/blob.d.ts +6 -0
- package/dist/http/blob.d.ts.map +1 -0
- package/dist/http/blob.js +46 -0
- package/dist/http/blob.js.map +1 -0
- package/dist/http/generated/api.d.ts +5241 -0
- package/dist/http/generated/api.d.ts.map +1 -0
- package/dist/http/generated/api.js +6 -0
- package/dist/http/generated/api.js.map +1 -0
- package/dist/http/index.d.ts +18 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +212 -0
- package/dist/http/index.js.map +1 -0
- package/dist/http/shared.d.ts +45 -0
- package/dist/http/shared.d.ts.map +1 -0
- package/dist/http/shared.js +122 -0
- package/dist/http/shared.js.map +1 -0
- package/dist/http/transport-client.d.ts +31 -0
- package/dist/http/transport-client.d.ts.map +1 -0
- package/dist/http/transport-client.js +193 -0
- package/dist/http/transport-client.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -15
- package/dist/index.js.map +1 -1
- package/dist/kysely-column-codecs.d.ts +17 -1
- package/dist/kysely-column-codecs.d.ts.map +1 -1
- package/dist/kysely-column-codecs.js +56 -70
- package/dist/kysely-column-codecs.js.map +1 -1
- package/dist/logger.js +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/schemas/blobs.d.ts +7 -1
- package/dist/schemas/blobs.d.ts.map +1 -1
- package/dist/schemas/blobs.js +1 -0
- package/dist/schemas/blobs.js.map +1 -1
- package/dist/schemas/common.d.ts +73 -1
- package/dist/schemas/common.d.ts.map +1 -1
- package/dist/schemas/common.js +38 -0
- package/dist/schemas/common.js.map +1 -1
- package/dist/schemas/index.js +3 -3
- package/dist/schemas/sync.d.ts +712 -38
- package/dist/schemas/sync.d.ts.map +1 -1
- package/dist/schemas/sync.js +209 -5
- package/dist/schemas/sync.js.map +1 -1
- package/dist/scopes/index.d.ts +5 -0
- package/dist/scopes/index.d.ts.map +1 -1
- package/dist/scopes/index.js +27 -0
- package/dist/scopes/index.js.map +1 -1
- package/dist/sentry.d.ts +31 -0
- package/dist/sentry.d.ts.map +1 -0
- package/dist/sentry.js +147 -0
- package/dist/sentry.js.map +1 -0
- package/dist/snapshot-chunks.d.ts +104 -20
- package/dist/snapshot-chunks.d.ts.map +1 -1
- package/dist/snapshot-chunks.js +665 -72
- package/dist/snapshot-chunks.js.map +1 -1
- package/dist/sync-packs.d.ts +33 -0
- package/dist/sync-packs.d.ts.map +1 -0
- package/dist/sync-packs.js +821 -0
- package/dist/sync-packs.js.map +1 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +3 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/transforms.js.map +1 -1
- package/dist/types.d.ts +48 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/bytes.d.ts +4 -0
- package/dist/utils/bytes.d.ts.map +1 -0
- package/dist/utils/bytes.js +66 -0
- package/dist/utils/bytes.js.map +1 -0
- package/dist/utils/compression.d.ts +11 -3
- package/dist/utils/compression.d.ts.map +1 -1
- package/dist/utils/compression.js +102 -50
- package/dist/utils/compression.js.map +1 -1
- package/dist/utils/crypto.d.ts +7 -1
- package/dist/utils/crypto.d.ts.map +1 -1
- package/dist/utils/crypto.js +89 -7
- package/dist/utils/crypto.js.map +1 -1
- package/dist/utils/id.d.ts.map +1 -1
- package/dist/utils/id.js +4 -1
- package/dist/utils/id.js.map +1 -1
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +9 -4
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/internal-runtime.d.ts +16 -0
- package/dist/utils/internal-runtime.d.ts.map +1 -0
- package/dist/utils/internal-runtime.js +14 -0
- package/dist/utils/internal-runtime.js.map +1 -0
- package/dist/utils/object.js.map +1 -1
- package/dist/utils/realtime-change-index.d.ts +9 -0
- package/dist/utils/realtime-change-index.d.ts.map +1 -0
- package/dist/utils/realtime-change-index.js +36 -0
- package/dist/utils/realtime-change-index.js.map +1 -0
- package/dist/utils/realtime-connection-registry.d.ts +40 -0
- package/dist/utils/realtime-connection-registry.d.ts.map +1 -0
- package/dist/utils/realtime-connection-registry.js +237 -0
- package/dist/utils/realtime-connection-registry.js.map +1 -0
- package/dist/utils/table-registry.d.ts +10 -0
- package/dist/utils/table-registry.d.ts.map +1 -0
- package/dist/utils/table-registry.js +54 -0
- package/dist/utils/table-registry.js.map +1 -0
- package/dist/utils/url.d.ts +3 -0
- package/dist/utils/url.d.ts.map +1 -0
- package/dist/utils/url.js +20 -0
- package/dist/utils/url.js.map +1 -0
- package/package.json +52 -7
- package/src/blobs.ts +24 -7
- package/src/column-codecs.ts +36 -8
- package/src/error-responses.ts +423 -0
- package/src/http/api-client.ts +39 -0
- package/src/http/blob.ts +95 -0
- package/src/http/generated/api.ts +5241 -0
- package/src/http/index.ts +365 -0
- package/src/http/shared.ts +220 -0
- package/src/http/transport-client.ts +314 -0
- package/src/index.ts +4 -0
- package/src/kysely-column-codecs.ts +81 -86
- package/src/schemas/blobs.ts +10 -1
- package/src/schemas/common.ts +44 -0
- package/src/schemas/sync.ts +298 -7
- package/src/scopes/index.ts +42 -0
- package/src/sentry.ts +200 -0
- package/src/snapshot-chunks.ts +921 -69
- package/src/sync-packs.ts +1096 -0
- package/src/telemetry.ts +7 -2
- package/src/types.ts +63 -8
- package/src/utils/bytes.ts +75 -0
- package/src/utils/compression.ts +147 -51
- package/src/utils/crypto.ts +120 -7
- package/src/utils/id.ts +11 -1
- package/src/utils/index.ts +5 -0
- package/src/utils/internal-runtime.ts +39 -0
- package/src/utils/realtime-change-index.ts +44 -0
- package/src/utils/realtime-connection-registry.ts +286 -0
- package/src/utils/table-registry.ts +67 -0
- package/src/utils/url.ts +29 -0
- package/src/__tests__/compression.test.ts +0 -54
- package/src/__tests__/conflict.test.ts +0 -325
- package/src/__tests__/telemetry.test.ts +0 -170
- package/src/__tests__/utils.test.ts +0 -27
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/core/http - HTTP transport for Sync
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - a lightweight fetch-based SyncTransport for client runtime use
|
|
6
|
+
* - a separately exported typed API client for advanced/console use
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
SyncBootstrapApplyMode,
|
|
11
|
+
SyncCombinedRequest,
|
|
12
|
+
SyncCombinedResponse,
|
|
13
|
+
SyncTransport,
|
|
14
|
+
SyncTransportCapabilities,
|
|
15
|
+
SyncTransportOptions,
|
|
16
|
+
} from '@syncular/core';
|
|
17
|
+
import { SyncTransportError } from '@syncular/core';
|
|
18
|
+
import type { SyncClient } from './api-client';
|
|
19
|
+
import {
|
|
20
|
+
applySnapshotScopesHeader,
|
|
21
|
+
bytesToReadableStream,
|
|
22
|
+
type ClientOptions,
|
|
23
|
+
executeWithAuthRetry,
|
|
24
|
+
executeWithTransientNetworkRetry,
|
|
25
|
+
getErrorMessage,
|
|
26
|
+
resolveSnapshotChunkRequestUrl,
|
|
27
|
+
type SyncTransportPath,
|
|
28
|
+
unwrap,
|
|
29
|
+
} from './shared';
|
|
30
|
+
import {
|
|
31
|
+
createTransportApiClient,
|
|
32
|
+
createTransportAuthRetryResolver,
|
|
33
|
+
HTTP_TRANSPORT_SOURCE,
|
|
34
|
+
type HttpTransportSource,
|
|
35
|
+
} from './transport-client';
|
|
36
|
+
|
|
37
|
+
export type {
|
|
38
|
+
SyncAuthErrorContext,
|
|
39
|
+
SyncAuthLifecycle,
|
|
40
|
+
SyncAuthOperation,
|
|
41
|
+
SyncTransport,
|
|
42
|
+
SyncTransportOptions,
|
|
43
|
+
} from '@syncular/core';
|
|
44
|
+
export { createApiClient } from './api-client';
|
|
45
|
+
export type { operations } from './generated/api';
|
|
46
|
+
export { type ClientOptions, type SyncClient, type SyncTransportPath, unwrap };
|
|
47
|
+
|
|
48
|
+
function detectDefaultTransportCapabilities(): SyncTransportCapabilities {
|
|
49
|
+
const isReactNative =
|
|
50
|
+
typeof navigator !== 'undefined' && navigator?.product === 'ReactNative';
|
|
51
|
+
const snapshotChunkReadMode = isReactNative ? 'bytes' : 'stream';
|
|
52
|
+
const gzipDecompressionMode =
|
|
53
|
+
!isReactNative && typeof DecompressionStream !== 'undefined'
|
|
54
|
+
? 'stream'
|
|
55
|
+
: 'buffered';
|
|
56
|
+
const preferredBootstrapApplyMode: SyncBootstrapApplyMode =
|
|
57
|
+
snapshotChunkReadMode === 'bytes' || gzipDecompressionMode === 'buffered'
|
|
58
|
+
? 'per-subscription'
|
|
59
|
+
: 'single-transaction';
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
snapshotChunkReadMode,
|
|
63
|
+
gzipDecompressionMode,
|
|
64
|
+
preferredBootstrapApplyMode,
|
|
65
|
+
preferredSnapshotApplyYieldMs: isReactNative ? 0 : false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mergeTransportCapabilities(
|
|
70
|
+
overrides?: Partial<SyncTransportCapabilities>
|
|
71
|
+
): SyncTransportCapabilities {
|
|
72
|
+
const defaults = detectDefaultTransportCapabilities();
|
|
73
|
+
const merged: SyncTransportCapabilities = {
|
|
74
|
+
...defaults,
|
|
75
|
+
...overrides,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
overrides?.preferredBootstrapApplyMode !== undefined &&
|
|
80
|
+
overrides?.preferredSnapshotApplyYieldMs !== undefined
|
|
81
|
+
) {
|
|
82
|
+
return merged;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const preferredBootstrapApplyMode: SyncBootstrapApplyMode =
|
|
86
|
+
merged.snapshotChunkReadMode === 'bytes' ||
|
|
87
|
+
merged.gzipDecompressionMode === 'buffered'
|
|
88
|
+
? 'per-subscription'
|
|
89
|
+
: 'single-transaction';
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
...merged,
|
|
93
|
+
preferredBootstrapApplyMode:
|
|
94
|
+
overrides?.preferredBootstrapApplyMode ?? preferredBootstrapApplyMode,
|
|
95
|
+
preferredSnapshotApplyYieldMs:
|
|
96
|
+
overrides?.preferredSnapshotApplyYieldMs ??
|
|
97
|
+
(merged.snapshotChunkReadMode === 'bytes' ||
|
|
98
|
+
merged.gzipDecompressionMode === 'buffered'
|
|
99
|
+
? 0
|
|
100
|
+
: false),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function shouldUseResponseBodyStream(
|
|
105
|
+
response: Response,
|
|
106
|
+
capabilities: SyncTransportCapabilities
|
|
107
|
+
): boolean {
|
|
108
|
+
if (!response.body) return false;
|
|
109
|
+
if (capabilities.snapshotChunkReadMode === 'bytes') {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return (
|
|
113
|
+
typeof (response.body as ReadableStream<Uint8Array>).getReader ===
|
|
114
|
+
'function'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const REACT_NATIVE_TRANSPORT_CAPABILITIES: SyncTransportCapabilities = {
|
|
119
|
+
snapshotChunkReadMode: 'bytes',
|
|
120
|
+
gzipDecompressionMode: 'buffered',
|
|
121
|
+
preferredBootstrapApplyMode: 'per-subscription',
|
|
122
|
+
preferredSnapshotApplyYieldMs: 0,
|
|
123
|
+
preferMaterializedSnapshots: true,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export function createHttpTransport(
|
|
127
|
+
clientOrOptions: SyncClient | ClientOptions
|
|
128
|
+
): SyncTransport {
|
|
129
|
+
const client = createTransportApiClient(clientOrOptions);
|
|
130
|
+
const resolveAuthRetry = createTransportAuthRetryResolver(clientOrOptions);
|
|
131
|
+
const transportOptions =
|
|
132
|
+
'baseUrl' in clientOrOptions ? clientOrOptions : undefined;
|
|
133
|
+
const capabilities =
|
|
134
|
+
'baseUrl' in clientOrOptions
|
|
135
|
+
? mergeTransportCapabilities(clientOrOptions.capabilities)
|
|
136
|
+
: mergeTransportCapabilities();
|
|
137
|
+
|
|
138
|
+
const transport: SyncTransport = {
|
|
139
|
+
capabilities,
|
|
140
|
+
async sync(
|
|
141
|
+
request: SyncCombinedRequest,
|
|
142
|
+
transportRequestOptions?: SyncTransportOptions
|
|
143
|
+
): Promise<SyncCombinedResponse> {
|
|
144
|
+
const { data, error, response } = await executeWithTransientNetworkRetry({
|
|
145
|
+
execute: (signal) =>
|
|
146
|
+
executeWithAuthRetry(
|
|
147
|
+
(retrySignal) => client.sync(request, retrySignal),
|
|
148
|
+
{ ...transportRequestOptions, signal },
|
|
149
|
+
'sync',
|
|
150
|
+
resolveAuthRetry
|
|
151
|
+
),
|
|
152
|
+
options: transportRequestOptions,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (error || !data) {
|
|
156
|
+
throw new SyncTransportError(
|
|
157
|
+
`Sync failed: ${getErrorMessage(error) || response.statusText}`,
|
|
158
|
+
response.status
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return data as SyncCombinedResponse;
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async fetchSnapshotChunk(
|
|
166
|
+
request: {
|
|
167
|
+
chunkId: string;
|
|
168
|
+
scopeValues?: Record<string, string | string[]>;
|
|
169
|
+
},
|
|
170
|
+
options?: SyncTransportOptions
|
|
171
|
+
): Promise<Uint8Array> {
|
|
172
|
+
if (!transportOptions) {
|
|
173
|
+
const { data, error, response } = await executeWithAuthRetry(
|
|
174
|
+
(signal) =>
|
|
175
|
+
client.getSnapshotChunk(
|
|
176
|
+
request.chunkId,
|
|
177
|
+
request.scopeValues,
|
|
178
|
+
signal
|
|
179
|
+
),
|
|
180
|
+
options,
|
|
181
|
+
'snapshotChunk',
|
|
182
|
+
resolveAuthRetry
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (error || !data) {
|
|
186
|
+
throw new SyncTransportError(
|
|
187
|
+
`Snapshot chunk download failed: ${getErrorMessage(error) || response.statusText}`,
|
|
188
|
+
response.status
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return new Uint8Array(await (data as Blob).arrayBuffer());
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const fetchImpl = transportOptions.fetch ?? globalThis.fetch;
|
|
196
|
+
const requestUrl = resolveSnapshotChunkRequestUrl(
|
|
197
|
+
transportOptions.baseUrl,
|
|
198
|
+
request.chunkId,
|
|
199
|
+
request.scopeValues
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const performRequest = async (
|
|
203
|
+
signal?: AbortSignal
|
|
204
|
+
): Promise<Response> => {
|
|
205
|
+
const headers = new Headers();
|
|
206
|
+
applySnapshotScopesHeader(headers, request.scopeValues);
|
|
207
|
+
const extraHeaders = await transportOptions.getHeaders?.();
|
|
208
|
+
if (extraHeaders) {
|
|
209
|
+
for (const [key, value] of Object.entries(extraHeaders)) {
|
|
210
|
+
headers.set(key, value);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!headers.has('x-syncular-transport-path')) {
|
|
214
|
+
headers.set(
|
|
215
|
+
'x-syncular-transport-path',
|
|
216
|
+
transportOptions.transportPath ?? 'direct'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return fetchImpl(requestUrl, {
|
|
220
|
+
method: 'GET',
|
|
221
|
+
headers,
|
|
222
|
+
...(signal ? { signal } : {}),
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
let response = await performRequest(options?.signal);
|
|
227
|
+
if (response.status === 401 || response.status === 403) {
|
|
228
|
+
const shouldRetry = await resolveAuthRetry(
|
|
229
|
+
{
|
|
230
|
+
operation: 'snapshotChunk',
|
|
231
|
+
status: response.status,
|
|
232
|
+
},
|
|
233
|
+
options
|
|
234
|
+
);
|
|
235
|
+
if (shouldRetry) {
|
|
236
|
+
response = await performRequest(options?.signal);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
let reason = response.statusText || 'Request failed';
|
|
242
|
+
try {
|
|
243
|
+
const maybeJson = (await response.json()) as
|
|
244
|
+
| { error?: unknown; message?: unknown }
|
|
245
|
+
| undefined;
|
|
246
|
+
reason = getErrorMessage(maybeJson) || reason;
|
|
247
|
+
} catch {
|
|
248
|
+
// ignore parse failures
|
|
249
|
+
}
|
|
250
|
+
throw new SyncTransportError(
|
|
251
|
+
`Snapshot chunk download failed: ${reason}`,
|
|
252
|
+
response.status
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async fetchSnapshotChunkStream(
|
|
260
|
+
request: {
|
|
261
|
+
chunkId: string;
|
|
262
|
+
scopeValues?: Record<string, string | string[]>;
|
|
263
|
+
},
|
|
264
|
+
options?: SyncTransportOptions
|
|
265
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
266
|
+
if (!transportOptions) {
|
|
267
|
+
const bytes = await this.fetchSnapshotChunk(request, options);
|
|
268
|
+
return bytesToReadableStream(bytes);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const fetchImpl = transportOptions.fetch ?? globalThis.fetch;
|
|
272
|
+
const requestUrl = resolveSnapshotChunkRequestUrl(
|
|
273
|
+
transportOptions.baseUrl,
|
|
274
|
+
request.chunkId,
|
|
275
|
+
request.scopeValues
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const performRequest = async (
|
|
279
|
+
signal?: AbortSignal
|
|
280
|
+
): Promise<Response> => {
|
|
281
|
+
const headers = new Headers();
|
|
282
|
+
applySnapshotScopesHeader(headers, request.scopeValues);
|
|
283
|
+
const extraHeaders = await transportOptions.getHeaders?.();
|
|
284
|
+
if (extraHeaders) {
|
|
285
|
+
for (const [key, value] of Object.entries(extraHeaders)) {
|
|
286
|
+
headers.set(key, value);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (!headers.has('x-syncular-transport-path')) {
|
|
290
|
+
headers.set(
|
|
291
|
+
'x-syncular-transport-path',
|
|
292
|
+
transportOptions.transportPath ?? 'direct'
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return fetchImpl(requestUrl, {
|
|
296
|
+
method: 'GET',
|
|
297
|
+
headers,
|
|
298
|
+
...(signal ? { signal } : {}),
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
let response = await performRequest(options?.signal);
|
|
303
|
+
if (response.status === 401 || response.status === 403) {
|
|
304
|
+
const shouldRetry = await resolveAuthRetry(
|
|
305
|
+
{
|
|
306
|
+
operation: 'snapshotChunkStream',
|
|
307
|
+
status: response.status,
|
|
308
|
+
},
|
|
309
|
+
options
|
|
310
|
+
);
|
|
311
|
+
if (shouldRetry) {
|
|
312
|
+
response = await performRequest(options?.signal);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
let reason = response.statusText || 'Request failed';
|
|
318
|
+
try {
|
|
319
|
+
const maybeJson = (await response.json()) as
|
|
320
|
+
| { error?: unknown; message?: unknown }
|
|
321
|
+
| undefined;
|
|
322
|
+
reason = getErrorMessage(maybeJson) || reason;
|
|
323
|
+
} catch {
|
|
324
|
+
// ignore parse failures
|
|
325
|
+
}
|
|
326
|
+
throw new SyncTransportError(
|
|
327
|
+
`Snapshot chunk download failed: ${reason}`,
|
|
328
|
+
response.status
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!shouldUseResponseBodyStream(response, capabilities)) {
|
|
333
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
334
|
+
return bytesToReadableStream(bytes);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return response.body as ReadableStream<Uint8Array>;
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
Object.defineProperty(transport, HTTP_TRANSPORT_SOURCE, {
|
|
342
|
+
configurable: false,
|
|
343
|
+
enumerable: false,
|
|
344
|
+
value: clientOrOptions as HttpTransportSource,
|
|
345
|
+
writable: false,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
return transport;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function createReactNativeHttpTransport(
|
|
352
|
+
clientOrOptions: SyncClient | ClientOptions
|
|
353
|
+
): SyncTransport {
|
|
354
|
+
if (!('baseUrl' in clientOrOptions)) {
|
|
355
|
+
return createHttpTransport(clientOrOptions);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return createHttpTransport({
|
|
359
|
+
...clientOrOptions,
|
|
360
|
+
capabilities: {
|
|
361
|
+
...REACT_NATIVE_TRANSPORT_CAPABILITIES,
|
|
362
|
+
...clientOrOptions.capabilities,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ScopeValues,
|
|
3
|
+
SyncAuthErrorContext,
|
|
4
|
+
SyncAuthLifecycle,
|
|
5
|
+
SyncAuthOperation,
|
|
6
|
+
SyncTransportCapabilities,
|
|
7
|
+
SyncTransportOptions,
|
|
8
|
+
} from '@syncular/core';
|
|
9
|
+
import { resolveUrlFromBase } from '@syncular/core';
|
|
10
|
+
|
|
11
|
+
export type SyncTransportPath = 'direct' | 'relay';
|
|
12
|
+
|
|
13
|
+
export interface ClientOptions {
|
|
14
|
+
/** Base URL for the API (e.g., 'https://api.example.com') */
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
/** Function to get headers for requests (e.g., for auth tokens) */
|
|
17
|
+
getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
18
|
+
/** Shared auth lifecycle for all transport operations. */
|
|
19
|
+
authLifecycle?: SyncAuthLifecycle;
|
|
20
|
+
/** Custom fetch implementation (defaults to globalThis.fetch) */
|
|
21
|
+
fetch?: typeof globalThis.fetch;
|
|
22
|
+
/**
|
|
23
|
+
* Transport path telemetry sent to the server.
|
|
24
|
+
* Defaults to 'direct'.
|
|
25
|
+
*/
|
|
26
|
+
transportPath?: SyncTransportPath;
|
|
27
|
+
/**
|
|
28
|
+
* Explicit runtime capabilities override.
|
|
29
|
+
* Use this to avoid environment sniffing in runtimes like React Native.
|
|
30
|
+
*/
|
|
31
|
+
capabilities?: Partial<SyncTransportCapabilities>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ApiResult<T> = {
|
|
35
|
+
data?: T;
|
|
36
|
+
error?: unknown;
|
|
37
|
+
response: Response;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ResolveAuthRetry = (
|
|
41
|
+
context: SyncAuthErrorContext,
|
|
42
|
+
options?: SyncTransportOptions
|
|
43
|
+
) => Promise<boolean>;
|
|
44
|
+
|
|
45
|
+
const TRANSIENT_NETWORK_RETRY_DELAYS_MS = [75, 150, 300] as const;
|
|
46
|
+
|
|
47
|
+
class ApiResponseError extends Error {
|
|
48
|
+
constructor(
|
|
49
|
+
message: string,
|
|
50
|
+
public readonly error?: unknown
|
|
51
|
+
) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'ApiResponseError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getErrorMessage(error: unknown): string {
|
|
58
|
+
if (error && typeof error === 'object' && 'error' in error) {
|
|
59
|
+
const inner = (error as { error: unknown }).error;
|
|
60
|
+
if (typeof inner === 'string') return inner;
|
|
61
|
+
}
|
|
62
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
63
|
+
const msg = (error as { message: unknown }).message;
|
|
64
|
+
if (typeof msg === 'string') return msg;
|
|
65
|
+
}
|
|
66
|
+
return 'Request failed';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function unwrap<T>(
|
|
70
|
+
promise: Promise<{ data?: T; error?: unknown }>
|
|
71
|
+
): Promise<T> {
|
|
72
|
+
const { data, error } = await promise;
|
|
73
|
+
if (error || !data) {
|
|
74
|
+
throw new ApiResponseError(getErrorMessage(error), error);
|
|
75
|
+
}
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function executeWithAuthRetry<T>(
|
|
80
|
+
execute: (signal?: AbortSignal) => Promise<ApiResult<T>>,
|
|
81
|
+
options: SyncTransportOptions | undefined,
|
|
82
|
+
operation: SyncAuthOperation,
|
|
83
|
+
resolveAuthRetry: ResolveAuthRetry
|
|
84
|
+
): Promise<ApiResult<T>> {
|
|
85
|
+
const first = await execute(options?.signal);
|
|
86
|
+
if (first.response.status !== 401 && first.response.status !== 403) {
|
|
87
|
+
return first;
|
|
88
|
+
}
|
|
89
|
+
const shouldRetry = await resolveAuthRetry(
|
|
90
|
+
{ operation, status: first.response.status },
|
|
91
|
+
options
|
|
92
|
+
);
|
|
93
|
+
if (!shouldRetry) {
|
|
94
|
+
return first;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return execute(options?.signal);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isAbortError(error: unknown): boolean {
|
|
101
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isTransientNetworkError(error: unknown): boolean {
|
|
105
|
+
if (isAbortError(error)) return false;
|
|
106
|
+
|
|
107
|
+
const message =
|
|
108
|
+
error instanceof Error
|
|
109
|
+
? error.message.toLowerCase()
|
|
110
|
+
: String(error).toLowerCase();
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
message.includes('fetch failed') ||
|
|
114
|
+
message.includes('network') ||
|
|
115
|
+
message.includes('socket') ||
|
|
116
|
+
message.includes('econnreset') ||
|
|
117
|
+
message.includes('econnrefused') ||
|
|
118
|
+
message.includes('connection refused') ||
|
|
119
|
+
message.includes('connection reset') ||
|
|
120
|
+
message.includes('terminated') ||
|
|
121
|
+
message.includes('eof')
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function delay(ms: number): Promise<void> {
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
setTimeout(resolve, ms);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function executeWithTransientNetworkRetry<T>(args: {
|
|
132
|
+
execute: (signal?: AbortSignal) => Promise<T>;
|
|
133
|
+
options?: SyncTransportOptions;
|
|
134
|
+
}): Promise<T> {
|
|
135
|
+
let lastError: unknown;
|
|
136
|
+
|
|
137
|
+
for (
|
|
138
|
+
let attemptIndex = 0;
|
|
139
|
+
attemptIndex <= TRANSIENT_NETWORK_RETRY_DELAYS_MS.length;
|
|
140
|
+
attemptIndex += 1
|
|
141
|
+
) {
|
|
142
|
+
try {
|
|
143
|
+
return await args.execute(args.options?.signal);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
lastError = error;
|
|
146
|
+
if (!isTransientNetworkError(error) || args.options?.signal?.aborted) {
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const delayMs = TRANSIENT_NETWORK_RETRY_DELAYS_MS[attemptIndex];
|
|
151
|
+
if (delayMs === undefined) {
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await delay(delayMs);
|
|
156
|
+
|
|
157
|
+
if (args.options?.signal?.aborted) {
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
throw lastError;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const SNAPSHOT_SCOPES_HEADER = 'x-syncular-snapshot-scopes';
|
|
167
|
+
|
|
168
|
+
export function resolveRequestUrl(baseUrl: string, path: string): string {
|
|
169
|
+
return resolveUrlFromBase(
|
|
170
|
+
baseUrl,
|
|
171
|
+
path,
|
|
172
|
+
typeof location === 'undefined' ? undefined : location.origin
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function bytesToReadableStream(
|
|
177
|
+
bytes: Uint8Array
|
|
178
|
+
): ReadableStream<Uint8Array> {
|
|
179
|
+
return new ReadableStream<Uint8Array>({
|
|
180
|
+
start(controller) {
|
|
181
|
+
controller.enqueue(bytes);
|
|
182
|
+
controller.close();
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function encodeSnapshotScopes(
|
|
188
|
+
scopeValues: ScopeValues | undefined
|
|
189
|
+
): string | null {
|
|
190
|
+
if (!scopeValues) return null;
|
|
191
|
+
if (Object.keys(scopeValues).length === 0) return null;
|
|
192
|
+
return JSON.stringify(scopeValues);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function applySnapshotScopesHeader(
|
|
196
|
+
headers: Headers,
|
|
197
|
+
scopeValues: ScopeValues | undefined
|
|
198
|
+
): void {
|
|
199
|
+
const encodedScopes = encodeSnapshotScopes(scopeValues);
|
|
200
|
+
if (!encodedScopes) return;
|
|
201
|
+
headers.set(SNAPSHOT_SCOPES_HEADER, encodedScopes);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function resolveSnapshotChunkRequestUrl(
|
|
205
|
+
baseUrl: string,
|
|
206
|
+
chunkId: string,
|
|
207
|
+
scopeValues: ScopeValues | undefined
|
|
208
|
+
): string {
|
|
209
|
+
const requestUrl = new URL(
|
|
210
|
+
resolveRequestUrl(
|
|
211
|
+
baseUrl,
|
|
212
|
+
`/sync/snapshot-chunks/${encodeURIComponent(chunkId)}`
|
|
213
|
+
)
|
|
214
|
+
);
|
|
215
|
+
const encodedScopes = encodeSnapshotScopes(scopeValues);
|
|
216
|
+
if (encodedScopes) {
|
|
217
|
+
requestUrl.searchParams.set('scopes', encodedScopes);
|
|
218
|
+
}
|
|
219
|
+
return requestUrl.toString();
|
|
220
|
+
}
|