@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/src/index.ts CHANGED
@@ -1,384 +1,64 @@
1
1
  /**
2
2
  * @syncular/transport-http - HTTP transport for Sync
3
3
  *
4
- * Provides typed API clients using openapi-fetch with auto-generated types.
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 { resolveUrlFromBase, SyncTransportError } from '@syncular/core';
19
- import createClient from 'openapi-fetch';
20
- import type { operations, paths } from './generated/api';
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
- 'GET' in clientOrOptions
254
- ? clientOrOptions
255
- : createApiClient(clientOrOptions);
49
+ const client = createTransportApiClient(clientOrOptions);
50
+ const resolveAuthRetry = createTransportAuthRetryResolver(clientOrOptions);
256
51
  const transportOptions =
257
- 'GET' in clientOrOptions ? undefined : clientOrOptions;
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
- async completeUpload(hash) {
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
- transportOptions?: SyncTransportOptions
57
+ transportRequestOptions?: SyncTransportOptions
374
58
  ): Promise<SyncCombinedResponse> {
375
59
  const { data, error, response } = await executeWithAuthRetry(
376
- (signal) =>
377
- client.POST('/sync', {
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.GET('/sync/snapshot-chunks/{chunkId}', {
409
- params: { path: { chunkId: request.chunkId } },
410
- parseAs: 'blob',
411
- headers: encodedScopes
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
+ }