@syncular/transport-http 0.0.6-185 → 0.0.6-201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/api-client.d.ts +11 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +30 -0
- package/dist/api-client.js.map +1 -0
- package/dist/blob.d.ts +6 -0
- package/dist/blob.d.ts.map +1 -0
- package/dist/blob.js +46 -0
- package/dist/blob.js.map +1 -0
- package/dist/generated/api.d.ts +42 -33
- package/dist/generated/api.d.ts.map +1 -1
- package/dist/index.d.ts +10 -71
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -238
- package/dist/index.js.map +1 -1
- package/dist/shared.d.ts +36 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +74 -0
- package/dist/shared.js.map +1 -0
- package/dist/transport-client.d.ts +31 -0
- package/dist/transport-client.d.ts.map +1 -0
- package/dist/transport-client.js +189 -0
- package/dist/transport-client.js.map +1 -0
- package/package.json +10 -2
- package/src/api-client.ts +39 -0
- package/src/blob.ts +95 -0
- package/src/generated/api.ts +42 -33
- package/src/index.ts +46 -367
- package/src/shared.ts +146 -0
- package/src/transport-client.ts +307 -0
package/src/index.ts
CHANGED
|
@@ -1,384 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @syncular/transport-http - HTTP transport for Sync
|
|
3
3
|
*
|
|
4
|
-
* Provides
|
|
4
|
+
* Provides:
|
|
5
|
+
* - a lightweight fetch-based SyncTransport for client runtime use
|
|
6
|
+
* - a separately exported typed API client for advanced/console use
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import type {
|
|
8
|
-
ScopeValues,
|
|
9
|
-
SyncAuthErrorContext,
|
|
10
|
-
SyncAuthLifecycle,
|
|
11
|
-
SyncAuthOperation,
|
|
12
10
|
SyncCombinedRequest,
|
|
13
11
|
SyncCombinedResponse,
|
|
14
12
|
SyncTransport,
|
|
15
|
-
SyncTransportBlobs,
|
|
16
13
|
SyncTransportOptions,
|
|
17
14
|
} from '@syncular/core';
|
|
18
|
-
import {
|
|
19
|
-
import
|
|
20
|
-
import
|
|
15
|
+
import { SyncTransportError } from '@syncular/core';
|
|
16
|
+
import type { SyncClient } from './api-client';
|
|
17
|
+
import {
|
|
18
|
+
applySnapshotScopesHeader,
|
|
19
|
+
bytesToReadableStream,
|
|
20
|
+
type ClientOptions,
|
|
21
|
+
executeWithAuthRetry,
|
|
22
|
+
getErrorMessage,
|
|
23
|
+
resolveSnapshotChunkRequestUrl,
|
|
24
|
+
type SyncTransportPath,
|
|
25
|
+
unwrap,
|
|
26
|
+
} from './shared';
|
|
27
|
+
import {
|
|
28
|
+
createTransportApiClient,
|
|
29
|
+
createTransportAuthRetryResolver,
|
|
30
|
+
HTTP_TRANSPORT_SOURCE,
|
|
31
|
+
type HttpTransportSource,
|
|
32
|
+
} from './transport-client';
|
|
33
|
+
|
|
34
|
+
export { type SyncClient, type ClientOptions, type SyncTransportPath, unwrap };
|
|
21
35
|
|
|
22
36
|
export type {
|
|
23
37
|
SyncAuthErrorContext,
|
|
24
38
|
SyncAuthLifecycle,
|
|
25
39
|
SyncAuthOperation,
|
|
26
40
|
SyncTransport,
|
|
27
|
-
SyncTransportBlobs,
|
|
28
41
|
SyncTransportOptions,
|
|
29
42
|
} from '@syncular/core';
|
|
43
|
+
export { createApiClient } from './api-client';
|
|
44
|
+
export type { operations } from './generated/api';
|
|
30
45
|
|
|
31
|
-
/**
|
|
32
|
-
* Error thrown when unwrapping an API response fails.
|
|
33
|
-
*/
|
|
34
|
-
class ApiResponseError extends Error {
|
|
35
|
-
constructor(
|
|
36
|
-
message: string,
|
|
37
|
-
public readonly error?: unknown
|
|
38
|
-
) {
|
|
39
|
-
super(message);
|
|
40
|
-
this.name = 'ApiResponseError';
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Helper to unwrap openapi-fetch responses.
|
|
46
|
-
* Throws ApiResponseError if the response contains an error or no data.
|
|
47
|
-
*/
|
|
48
|
-
function getErrorMessage(error: unknown): string {
|
|
49
|
-
if (error && typeof error === 'object' && 'error' in error) {
|
|
50
|
-
const inner = (error as { error: unknown }).error;
|
|
51
|
-
if (typeof inner === 'string') return inner;
|
|
52
|
-
}
|
|
53
|
-
if (error && typeof error === 'object' && 'message' in error) {
|
|
54
|
-
const msg = (error as { message: unknown }).message;
|
|
55
|
-
if (typeof msg === 'string') return msg;
|
|
56
|
-
}
|
|
57
|
-
return 'Request failed';
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function unwrap<T>(
|
|
61
|
-
promise: Promise<{ data?: T; error?: unknown }>
|
|
62
|
-
): Promise<T> {
|
|
63
|
-
const { data, error } = await promise;
|
|
64
|
-
if (error || !data) {
|
|
65
|
-
throw new ApiResponseError(getErrorMessage(error), error);
|
|
66
|
-
}
|
|
67
|
-
return data;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
type ApiResult<T> = {
|
|
71
|
-
data?: T;
|
|
72
|
-
error?: unknown;
|
|
73
|
-
response: Response;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
type ResolveAuthRetry = (
|
|
77
|
-
context: SyncAuthErrorContext,
|
|
78
|
-
options?: SyncTransportOptions
|
|
79
|
-
) => Promise<boolean>;
|
|
80
|
-
|
|
81
|
-
async function executeWithAuthRetry<T>(
|
|
82
|
-
execute: (signal?: AbortSignal) => Promise<ApiResult<T>>,
|
|
83
|
-
options: SyncTransportOptions | undefined,
|
|
84
|
-
operation: SyncAuthOperation,
|
|
85
|
-
resolveAuthRetry: ResolveAuthRetry
|
|
86
|
-
): Promise<ApiResult<T>> {
|
|
87
|
-
const first = await execute(options?.signal);
|
|
88
|
-
if (first.response.status !== 401 && first.response.status !== 403) {
|
|
89
|
-
return first;
|
|
90
|
-
}
|
|
91
|
-
const shouldRetry = await resolveAuthRetry(
|
|
92
|
-
{ operation, status: first.response.status },
|
|
93
|
-
options
|
|
94
|
-
);
|
|
95
|
-
if (!shouldRetry) {
|
|
96
|
-
return first;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return execute(options?.signal);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Re-export useful types from the generated API
|
|
103
|
-
export type SyncClient = ReturnType<typeof createClient<paths>>;
|
|
104
|
-
export type SyncTransportPath = 'direct' | 'relay';
|
|
105
|
-
export type { operations };
|
|
106
|
-
|
|
107
|
-
export interface ClientOptions {
|
|
108
|
-
/** Base URL for the API (e.g., 'https://api.example.com') */
|
|
109
|
-
baseUrl: string;
|
|
110
|
-
/** Function to get headers for requests (e.g., for auth tokens) */
|
|
111
|
-
getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
112
|
-
/** Shared auth lifecycle for all transport operations. */
|
|
113
|
-
authLifecycle?: SyncAuthLifecycle;
|
|
114
|
-
/** Custom fetch implementation (defaults to globalThis.fetch) */
|
|
115
|
-
fetch?: typeof globalThis.fetch;
|
|
116
|
-
/**
|
|
117
|
-
* Transport path telemetry sent to the server.
|
|
118
|
-
* Defaults to 'direct'.
|
|
119
|
-
*/
|
|
120
|
-
transportPath?: SyncTransportPath;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const SNAPSHOT_SCOPES_HEADER = 'x-syncular-snapshot-scopes';
|
|
124
|
-
|
|
125
|
-
function resolveRequestUrl(baseUrl: string, path: string): string {
|
|
126
|
-
return resolveUrlFromBase(
|
|
127
|
-
baseUrl,
|
|
128
|
-
path,
|
|
129
|
-
typeof location === 'undefined' ? undefined : location.origin
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function bytesToReadableStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
134
|
-
return new ReadableStream<Uint8Array>({
|
|
135
|
-
start(controller) {
|
|
136
|
-
controller.enqueue(bytes);
|
|
137
|
-
controller.close();
|
|
138
|
-
},
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function encodeSnapshotScopes(
|
|
143
|
-
scopeValues: ScopeValues | undefined
|
|
144
|
-
): string | null {
|
|
145
|
-
if (!scopeValues) return null;
|
|
146
|
-
if (Object.keys(scopeValues).length === 0) return null;
|
|
147
|
-
return JSON.stringify(scopeValues);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function applySnapshotScopesHeader(
|
|
151
|
-
headers: Headers,
|
|
152
|
-
scopeValues: ScopeValues | undefined
|
|
153
|
-
): void {
|
|
154
|
-
const encodedScopes = encodeSnapshotScopes(scopeValues);
|
|
155
|
-
if (!encodedScopes) return;
|
|
156
|
-
headers.set(SNAPSHOT_SCOPES_HEADER, encodedScopes);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function resolveSnapshotChunkRequestUrl(
|
|
160
|
-
baseUrl: string,
|
|
161
|
-
chunkId: string,
|
|
162
|
-
scopeValues: ScopeValues | undefined
|
|
163
|
-
): string {
|
|
164
|
-
const requestUrl = new URL(
|
|
165
|
-
resolveRequestUrl(
|
|
166
|
-
baseUrl,
|
|
167
|
-
`/sync/snapshot-chunks/${encodeURIComponent(chunkId)}`
|
|
168
|
-
)
|
|
169
|
-
);
|
|
170
|
-
const encodedScopes = encodeSnapshotScopes(scopeValues);
|
|
171
|
-
if (encodedScopes) {
|
|
172
|
-
requestUrl.searchParams.set('scopes', encodedScopes);
|
|
173
|
-
}
|
|
174
|
-
return requestUrl.toString();
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Create a typed API client for the full Syncular API.
|
|
179
|
-
*
|
|
180
|
-
* Returns an openapi-fetch client with full type safety for all endpoints.
|
|
181
|
-
*
|
|
182
|
-
* @example
|
|
183
|
-
* ```typescript
|
|
184
|
-
* const client = createApiClient({
|
|
185
|
-
* baseUrl: 'https://api.example.com',
|
|
186
|
-
* getHeaders: () => ({ Authorization: `Bearer ${token}` }),
|
|
187
|
-
* });
|
|
188
|
-
*
|
|
189
|
-
* // Sync endpoints
|
|
190
|
-
* const { data } = await client.POST('/sync', { body: { clientId: 'c1', pull: { ... } } });
|
|
191
|
-
*
|
|
192
|
-
* // Console endpoints
|
|
193
|
-
* const { data: stats } = await client.GET('/console/stats');
|
|
194
|
-
* const { data: commits } = await client.GET('/console/commits', {
|
|
195
|
-
* params: { query: { limit: 50 } }
|
|
196
|
-
* });
|
|
197
|
-
* ```
|
|
198
|
-
*/
|
|
199
|
-
export function createApiClient(options: ClientOptions): SyncClient {
|
|
200
|
-
const client = createClient<paths>({
|
|
201
|
-
baseUrl: options.baseUrl,
|
|
202
|
-
...(options.fetch && { fetch: options.fetch }),
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
const getHeaders = options.getHeaders;
|
|
206
|
-
const transportPath = options.transportPath ?? 'direct';
|
|
207
|
-
|
|
208
|
-
client.use({
|
|
209
|
-
async onRequest({ request }) {
|
|
210
|
-
if (getHeaders) {
|
|
211
|
-
const headers = await getHeaders();
|
|
212
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
213
|
-
request.headers.set(key, value);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (!request.headers.has('x-syncular-transport-path')) {
|
|
218
|
-
request.headers.set('x-syncular-transport-path', transportPath);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return request;
|
|
222
|
-
},
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
return client;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Create a SyncTransport from an API client or options.
|
|
230
|
-
*
|
|
231
|
-
* The transport includes both sync and blob operations via the typed OpenAPI client.
|
|
232
|
-
*
|
|
233
|
-
* @example
|
|
234
|
-
* ```typescript
|
|
235
|
-
* // From options (convenience)
|
|
236
|
-
* const transport = createHttpTransport({
|
|
237
|
-
* baseUrl: 'https://api.example.com',
|
|
238
|
-
* getHeaders: () => ({ Authorization: `Bearer ${token}` }),
|
|
239
|
-
* });
|
|
240
|
-
*
|
|
241
|
-
* // Or from an existing client
|
|
242
|
-
* const client = createApiClient({ baseUrl: 'https://api.example.com' });
|
|
243
|
-
* const transport = createHttpTransport(client);
|
|
244
|
-
*
|
|
245
|
-
* // Use with Client
|
|
246
|
-
* const syncClient = new Client({ transport, ... });
|
|
247
|
-
* ```
|
|
248
|
-
*/
|
|
249
46
|
export function createHttpTransport(
|
|
250
47
|
clientOrOptions: SyncClient | ClientOptions
|
|
251
48
|
): SyncTransport {
|
|
252
|
-
const client =
|
|
253
|
-
|
|
254
|
-
? clientOrOptions
|
|
255
|
-
: createApiClient(clientOrOptions);
|
|
49
|
+
const client = createTransportApiClient(clientOrOptions);
|
|
50
|
+
const resolveAuthRetry = createTransportAuthRetryResolver(clientOrOptions);
|
|
256
51
|
const transportOptions =
|
|
257
|
-
'
|
|
258
|
-
const defaultAuthLifecycle = transportOptions?.authLifecycle;
|
|
259
|
-
|
|
260
|
-
let refreshInFlight: Promise<boolean> | null = null;
|
|
261
|
-
|
|
262
|
-
const runRefreshSingleFlight = async (
|
|
263
|
-
lifecycle: SyncAuthLifecycle,
|
|
264
|
-
context: SyncAuthErrorContext
|
|
265
|
-
): Promise<boolean> => {
|
|
266
|
-
if (!lifecycle.refreshToken) return false;
|
|
267
|
-
|
|
268
|
-
if (!refreshInFlight) {
|
|
269
|
-
refreshInFlight = Promise.resolve(lifecycle.refreshToken(context))
|
|
270
|
-
.then((result) => Boolean(result))
|
|
271
|
-
.finally(() => {
|
|
272
|
-
refreshInFlight = null;
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
return refreshInFlight;
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
const resolveAuthRetry: ResolveAuthRetry = async (context, options) => {
|
|
280
|
-
if (options?.onAuthError) {
|
|
281
|
-
return Boolean(await options.onAuthError());
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const lifecycle = options?.authLifecycle ?? defaultAuthLifecycle;
|
|
285
|
-
if (!lifecycle) return false;
|
|
286
|
-
|
|
287
|
-
await lifecycle.onAuthExpired?.(context);
|
|
288
|
-
|
|
289
|
-
const refreshResult = await runRefreshSingleFlight(lifecycle, context);
|
|
290
|
-
if (lifecycle.retryWithFreshToken) {
|
|
291
|
-
return Boolean(
|
|
292
|
-
await lifecycle.retryWithFreshToken({ ...context, refreshResult })
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
return refreshResult;
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
// Create blob operations using the typed OpenAPI client
|
|
299
|
-
const blobs: SyncTransportBlobs = {
|
|
300
|
-
async initiateUpload(args) {
|
|
301
|
-
const { data, error, response } = await executeWithAuthRetry(
|
|
302
|
-
(signal) =>
|
|
303
|
-
client.POST('/sync/blobs/upload', {
|
|
304
|
-
body: args,
|
|
305
|
-
...(signal ? { signal } : {}),
|
|
306
|
-
}),
|
|
307
|
-
undefined,
|
|
308
|
-
'blobInitiateUpload',
|
|
309
|
-
resolveAuthRetry
|
|
310
|
-
);
|
|
311
|
-
|
|
312
|
-
if (error || !data) {
|
|
313
|
-
throw new SyncTransportError(
|
|
314
|
-
`Blob upload init failed: ${getErrorMessage(error) || response.statusText}`,
|
|
315
|
-
response.status
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
return data;
|
|
320
|
-
},
|
|
52
|
+
'baseUrl' in clientOrOptions ? clientOrOptions : undefined;
|
|
321
53
|
|
|
322
|
-
|
|
323
|
-
const { data, error } = await executeWithAuthRetry(
|
|
324
|
-
(signal) =>
|
|
325
|
-
client.POST('/sync/blobs/{hash}/complete', {
|
|
326
|
-
params: { path: { hash } },
|
|
327
|
-
...(signal ? { signal } : {}),
|
|
328
|
-
}),
|
|
329
|
-
undefined,
|
|
330
|
-
'blobCompleteUpload',
|
|
331
|
-
resolveAuthRetry
|
|
332
|
-
);
|
|
333
|
-
|
|
334
|
-
if (error || !data) {
|
|
335
|
-
return {
|
|
336
|
-
ok: false,
|
|
337
|
-
error: getErrorMessage(error) || 'Complete upload failed',
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return data;
|
|
342
|
-
},
|
|
343
|
-
|
|
344
|
-
async getDownloadUrl(hash) {
|
|
345
|
-
const { data, error, response } = await executeWithAuthRetry(
|
|
346
|
-
(signal) =>
|
|
347
|
-
client.GET('/sync/blobs/{hash}/url', {
|
|
348
|
-
params: { path: { hash } },
|
|
349
|
-
...(signal ? { signal } : {}),
|
|
350
|
-
}),
|
|
351
|
-
undefined,
|
|
352
|
-
'blobGetDownloadUrl',
|
|
353
|
-
resolveAuthRetry
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
if (error || !data) {
|
|
357
|
-
throw new SyncTransportError(
|
|
358
|
-
`Get download URL failed: ${getErrorMessage(error) || response.statusText}`,
|
|
359
|
-
response.status
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
return {
|
|
364
|
-
url: data.url,
|
|
365
|
-
expiresAt: data.expiresAt,
|
|
366
|
-
};
|
|
367
|
-
},
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
return {
|
|
54
|
+
const transport: SyncTransport = {
|
|
371
55
|
async sync(
|
|
372
56
|
request: SyncCombinedRequest,
|
|
373
|
-
|
|
57
|
+
transportRequestOptions?: SyncTransportOptions
|
|
374
58
|
): Promise<SyncCombinedResponse> {
|
|
375
59
|
const { data, error, response } = await executeWithAuthRetry(
|
|
376
|
-
(signal) =>
|
|
377
|
-
|
|
378
|
-
body: request,
|
|
379
|
-
...(signal ? { signal } : {}),
|
|
380
|
-
}),
|
|
381
|
-
transportOptions,
|
|
60
|
+
(signal) => client.sync(request, signal),
|
|
61
|
+
transportRequestOptions,
|
|
382
62
|
'sync',
|
|
383
63
|
resolveAuthRetry
|
|
384
64
|
);
|
|
@@ -400,21 +80,14 @@ export function createHttpTransport(
|
|
|
400
80
|
},
|
|
401
81
|
options?: SyncTransportOptions
|
|
402
82
|
): Promise<Uint8Array> {
|
|
403
|
-
const encodedScopes = encodeSnapshotScopes(request.scopeValues);
|
|
404
|
-
|
|
405
83
|
if (!transportOptions) {
|
|
406
84
|
const { data, error, response } = await executeWithAuthRetry(
|
|
407
85
|
(signal) =>
|
|
408
|
-
client.
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
[SNAPSHOT_SCOPES_HEADER]: encodedScopes,
|
|
414
|
-
}
|
|
415
|
-
: undefined,
|
|
416
|
-
...(signal ? { signal } : {}),
|
|
417
|
-
}),
|
|
86
|
+
client.getSnapshotChunk(
|
|
87
|
+
request.chunkId,
|
|
88
|
+
request.scopeValues,
|
|
89
|
+
signal
|
|
90
|
+
),
|
|
418
91
|
options,
|
|
419
92
|
'snapshotChunk',
|
|
420
93
|
resolveAuthRetry
|
|
@@ -574,8 +247,14 @@ export function createHttpTransport(
|
|
|
574
247
|
|
|
575
248
|
return response.body as ReadableStream<Uint8Array>;
|
|
576
249
|
},
|
|
577
|
-
|
|
578
|
-
// Include blob operations
|
|
579
|
-
blobs,
|
|
580
250
|
};
|
|
251
|
+
|
|
252
|
+
Object.defineProperty(transport, HTTP_TRANSPORT_SOURCE, {
|
|
253
|
+
configurable: false,
|
|
254
|
+
enumerable: false,
|
|
255
|
+
value: clientOrOptions as HttpTransportSource,
|
|
256
|
+
writable: false,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return transport;
|
|
581
260
|
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ScopeValues,
|
|
3
|
+
SyncAuthErrorContext,
|
|
4
|
+
SyncAuthLifecycle,
|
|
5
|
+
SyncAuthOperation,
|
|
6
|
+
SyncTransportOptions,
|
|
7
|
+
} from '@syncular/core';
|
|
8
|
+
import { resolveUrlFromBase } from '@syncular/core';
|
|
9
|
+
|
|
10
|
+
export type SyncTransportPath = 'direct' | 'relay';
|
|
11
|
+
|
|
12
|
+
export interface ClientOptions {
|
|
13
|
+
/** Base URL for the API (e.g., 'https://api.example.com') */
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
/** Function to get headers for requests (e.g., for auth tokens) */
|
|
16
|
+
getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
17
|
+
/** Shared auth lifecycle for all transport operations. */
|
|
18
|
+
authLifecycle?: SyncAuthLifecycle;
|
|
19
|
+
/** Custom fetch implementation (defaults to globalThis.fetch) */
|
|
20
|
+
fetch?: typeof globalThis.fetch;
|
|
21
|
+
/**
|
|
22
|
+
* Transport path telemetry sent to the server.
|
|
23
|
+
* Defaults to 'direct'.
|
|
24
|
+
*/
|
|
25
|
+
transportPath?: SyncTransportPath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ApiResult<T> = {
|
|
29
|
+
data?: T;
|
|
30
|
+
error?: unknown;
|
|
31
|
+
response: Response;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ResolveAuthRetry = (
|
|
35
|
+
context: SyncAuthErrorContext,
|
|
36
|
+
options?: SyncTransportOptions
|
|
37
|
+
) => Promise<boolean>;
|
|
38
|
+
|
|
39
|
+
class ApiResponseError extends Error {
|
|
40
|
+
constructor(
|
|
41
|
+
message: string,
|
|
42
|
+
public readonly error?: unknown
|
|
43
|
+
) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'ApiResponseError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getErrorMessage(error: unknown): string {
|
|
50
|
+
if (error && typeof error === 'object' && 'error' in error) {
|
|
51
|
+
const inner = (error as { error: unknown }).error;
|
|
52
|
+
if (typeof inner === 'string') return inner;
|
|
53
|
+
}
|
|
54
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
55
|
+
const msg = (error as { message: unknown }).message;
|
|
56
|
+
if (typeof msg === 'string') return msg;
|
|
57
|
+
}
|
|
58
|
+
return 'Request failed';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function unwrap<T>(
|
|
62
|
+
promise: Promise<{ data?: T; error?: unknown }>
|
|
63
|
+
): Promise<T> {
|
|
64
|
+
const { data, error } = await promise;
|
|
65
|
+
if (error || !data) {
|
|
66
|
+
throw new ApiResponseError(getErrorMessage(error), error);
|
|
67
|
+
}
|
|
68
|
+
return data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function executeWithAuthRetry<T>(
|
|
72
|
+
execute: (signal?: AbortSignal) => Promise<ApiResult<T>>,
|
|
73
|
+
options: SyncTransportOptions | undefined,
|
|
74
|
+
operation: SyncAuthOperation,
|
|
75
|
+
resolveAuthRetry: ResolveAuthRetry
|
|
76
|
+
): Promise<ApiResult<T>> {
|
|
77
|
+
const first = await execute(options?.signal);
|
|
78
|
+
if (first.response.status !== 401 && first.response.status !== 403) {
|
|
79
|
+
return first;
|
|
80
|
+
}
|
|
81
|
+
const shouldRetry = await resolveAuthRetry(
|
|
82
|
+
{ operation, status: first.response.status },
|
|
83
|
+
options
|
|
84
|
+
);
|
|
85
|
+
if (!shouldRetry) {
|
|
86
|
+
return first;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return execute(options?.signal);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const SNAPSHOT_SCOPES_HEADER = 'x-syncular-snapshot-scopes';
|
|
93
|
+
|
|
94
|
+
export function resolveRequestUrl(baseUrl: string, path: string): string {
|
|
95
|
+
return resolveUrlFromBase(
|
|
96
|
+
baseUrl,
|
|
97
|
+
path,
|
|
98
|
+
typeof location === 'undefined' ? undefined : location.origin
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function bytesToReadableStream(
|
|
103
|
+
bytes: Uint8Array
|
|
104
|
+
): ReadableStream<Uint8Array> {
|
|
105
|
+
return new ReadableStream<Uint8Array>({
|
|
106
|
+
start(controller) {
|
|
107
|
+
controller.enqueue(bytes);
|
|
108
|
+
controller.close();
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function encodeSnapshotScopes(
|
|
114
|
+
scopeValues: ScopeValues | undefined
|
|
115
|
+
): string | null {
|
|
116
|
+
if (!scopeValues) return null;
|
|
117
|
+
if (Object.keys(scopeValues).length === 0) return null;
|
|
118
|
+
return JSON.stringify(scopeValues);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function applySnapshotScopesHeader(
|
|
122
|
+
headers: Headers,
|
|
123
|
+
scopeValues: ScopeValues | undefined
|
|
124
|
+
): void {
|
|
125
|
+
const encodedScopes = encodeSnapshotScopes(scopeValues);
|
|
126
|
+
if (!encodedScopes) return;
|
|
127
|
+
headers.set(SNAPSHOT_SCOPES_HEADER, encodedScopes);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function resolveSnapshotChunkRequestUrl(
|
|
131
|
+
baseUrl: string,
|
|
132
|
+
chunkId: string,
|
|
133
|
+
scopeValues: ScopeValues | undefined
|
|
134
|
+
): string {
|
|
135
|
+
const requestUrl = new URL(
|
|
136
|
+
resolveRequestUrl(
|
|
137
|
+
baseUrl,
|
|
138
|
+
`/sync/snapshot-chunks/${encodeURIComponent(chunkId)}`
|
|
139
|
+
)
|
|
140
|
+
);
|
|
141
|
+
const encodedScopes = encodeSnapshotScopes(scopeValues);
|
|
142
|
+
if (encodedScopes) {
|
|
143
|
+
requestUrl.searchParams.set('scopes', encodedScopes);
|
|
144
|
+
}
|
|
145
|
+
return requestUrl.toString();
|
|
146
|
+
}
|