@syncular/transport-http 0.0.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/index.ts ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * @syncular/transport-http - HTTP transport for Sync
3
+ *
4
+ * Provides typed API clients using openapi-fetch with auto-generated types.
5
+ */
6
+
7
+ import type {
8
+ SyncPullRequest,
9
+ SyncPullResponse,
10
+ SyncPushRequest,
11
+ SyncPushResponse,
12
+ SyncTransport,
13
+ SyncTransportBlobs,
14
+ SyncTransportOptions,
15
+ } from '@syncular/core';
16
+ import { SyncTransportError } from '@syncular/core';
17
+ import createClient from 'openapi-fetch';
18
+ import type { paths } from './generated/api';
19
+
20
+ export type {
21
+ SyncTransport,
22
+ SyncTransportBlobs,
23
+ SyncTransportOptions,
24
+ } from '@syncular/core';
25
+
26
+ /**
27
+ * Error thrown when unwrapping an API response fails.
28
+ */
29
+ class ApiResponseError extends Error {
30
+ constructor(
31
+ message: string,
32
+ public readonly error?: unknown
33
+ ) {
34
+ super(message);
35
+ this.name = 'ApiResponseError';
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Helper to unwrap openapi-fetch responses.
41
+ * Throws ApiResponseError if the response contains an error or no data.
42
+ */
43
+ function getErrorMessage(error: unknown): string {
44
+ if (error && typeof error === 'object' && 'error' in error) {
45
+ const inner = (error as { error: unknown }).error;
46
+ if (typeof inner === 'string') return inner;
47
+ }
48
+ if (error && typeof error === 'object' && 'message' in error) {
49
+ const msg = (error as { message: unknown }).message;
50
+ if (typeof msg === 'string') return msg;
51
+ }
52
+ return 'Request failed';
53
+ }
54
+
55
+ export async function unwrap<T>(
56
+ promise: Promise<{ data?: T; error?: unknown }>
57
+ ): Promise<T> {
58
+ const { data, error } = await promise;
59
+ if (error || !data) {
60
+ throw new ApiResponseError(getErrorMessage(error), error);
61
+ }
62
+ return data;
63
+ }
64
+
65
+ type ApiResult<T> = {
66
+ data?: T;
67
+ error?: unknown;
68
+ response: Response;
69
+ };
70
+
71
+ async function executeWithAuthRetry<T>(
72
+ execute: (signal?: AbortSignal) => Promise<ApiResult<T>>,
73
+ options?: SyncTransportOptions
74
+ ): Promise<ApiResult<T>> {
75
+ const first = await execute(options?.signal);
76
+ if (first.response.status !== 401 && first.response.status !== 403) {
77
+ return first;
78
+ }
79
+ if (!options?.onAuthError) {
80
+ return first;
81
+ }
82
+
83
+ const shouldRetry = await options.onAuthError();
84
+ if (!shouldRetry) {
85
+ return first;
86
+ }
87
+
88
+ return execute(options.signal);
89
+ }
90
+
91
+ // Re-export useful types from the generated API
92
+ export type SyncClient = ReturnType<typeof createClient<paths>>;
93
+ export type SyncTransportPath = 'direct' | 'relay';
94
+
95
+ export interface ClientOptions {
96
+ /** Base URL for the API (e.g., 'https://api.example.com') */
97
+ baseUrl: string;
98
+ /** Function to get headers for requests (e.g., for auth tokens) */
99
+ getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
100
+ /** Custom fetch implementation (defaults to globalThis.fetch) */
101
+ fetch?: typeof globalThis.fetch;
102
+ /**
103
+ * Transport path telemetry sent to the server.
104
+ * Defaults to 'direct'.
105
+ */
106
+ transportPath?: SyncTransportPath;
107
+ }
108
+
109
+ /**
110
+ * Create a typed API client for the full Syncular API.
111
+ *
112
+ * Returns an openapi-fetch client with full type safety for all endpoints.
113
+ *
114
+ * @example
115
+ * ```typescript
116
+ * const client = createApiClient({
117
+ * baseUrl: 'https://api.example.com',
118
+ * getHeaders: () => ({ Authorization: `Bearer ${token}` }),
119
+ * });
120
+ *
121
+ * // Sync endpoints
122
+ * const { data } = await client.POST('/sync/pull', { body: { ... } });
123
+ *
124
+ * // Console endpoints
125
+ * const { data: stats } = await client.GET('/console/stats');
126
+ * const { data: commits } = await client.GET('/console/commits', {
127
+ * params: { query: { limit: 50 } }
128
+ * });
129
+ * ```
130
+ */
131
+ export function createApiClient(options: ClientOptions): SyncClient {
132
+ const client = createClient<paths>({
133
+ baseUrl: options.baseUrl,
134
+ ...(options.fetch && { fetch: options.fetch }),
135
+ });
136
+
137
+ const getHeaders = options.getHeaders;
138
+ const transportPath = options.transportPath ?? 'direct';
139
+
140
+ client.use({
141
+ async onRequest({ request }) {
142
+ if (getHeaders) {
143
+ const headers = await getHeaders();
144
+ for (const [key, value] of Object.entries(headers)) {
145
+ request.headers.set(key, value);
146
+ }
147
+ }
148
+
149
+ if (!request.headers.has('x-syncular-transport-path')) {
150
+ request.headers.set('x-syncular-transport-path', transportPath);
151
+ }
152
+
153
+ return request;
154
+ },
155
+ });
156
+
157
+ return client;
158
+ }
159
+
160
+ /**
161
+ * Create a SyncTransport from an API client or options.
162
+ *
163
+ * The transport includes both sync and blob operations via the typed OpenAPI client.
164
+ *
165
+ * @example
166
+ * ```typescript
167
+ * // From options (convenience)
168
+ * const transport = createHttpTransport({
169
+ * baseUrl: 'https://api.example.com',
170
+ * getHeaders: () => ({ Authorization: `Bearer ${token}` }),
171
+ * });
172
+ *
173
+ * // Or from an existing client
174
+ * const client = createApiClient({ baseUrl: 'https://api.example.com' });
175
+ * const transport = createHttpTransport(client);
176
+ *
177
+ * // Use with Client
178
+ * const syncClient = new Client({ transport, ... });
179
+ * ```
180
+ */
181
+ export function createHttpTransport(
182
+ clientOrOptions: SyncClient | ClientOptions
183
+ ): SyncTransport {
184
+ const client =
185
+ 'GET' in clientOrOptions
186
+ ? clientOrOptions
187
+ : createApiClient(clientOrOptions);
188
+
189
+ // Create blob operations using the typed OpenAPI client
190
+ const blobs: SyncTransportBlobs = {
191
+ async initiateUpload(args) {
192
+ const { data, error, response } = await client.POST(
193
+ '/sync/blobs/upload',
194
+ {
195
+ body: args,
196
+ }
197
+ );
198
+
199
+ if (error || !data) {
200
+ throw new SyncTransportError(
201
+ `Blob upload init failed: ${getErrorMessage(error) || response.statusText}`,
202
+ response.status
203
+ );
204
+ }
205
+
206
+ return data;
207
+ },
208
+
209
+ async completeUpload(hash) {
210
+ const { data, error } = await client.POST('/sync/blobs/{hash}/complete', {
211
+ params: { path: { hash } },
212
+ });
213
+
214
+ if (error || !data) {
215
+ return {
216
+ ok: false,
217
+ error: getErrorMessage(error) || 'Complete upload failed',
218
+ };
219
+ }
220
+
221
+ return data;
222
+ },
223
+
224
+ async getDownloadUrl(hash) {
225
+ const { data, error, response } = await client.GET(
226
+ '/sync/blobs/{hash}/url',
227
+ {
228
+ params: { path: { hash } },
229
+ }
230
+ );
231
+
232
+ if (error || !data) {
233
+ throw new SyncTransportError(
234
+ `Get download URL failed: ${getErrorMessage(error) || response.statusText}`,
235
+ response.status
236
+ );
237
+ }
238
+
239
+ return {
240
+ url: data.url,
241
+ expiresAt: data.expiresAt,
242
+ };
243
+ },
244
+ };
245
+
246
+ return {
247
+ async pull(
248
+ request: SyncPullRequest,
249
+ transportOptions?: SyncTransportOptions
250
+ ): Promise<SyncPullResponse> {
251
+ const { data, error, response } = await executeWithAuthRetry(
252
+ (signal) =>
253
+ client.POST('/sync/pull', {
254
+ body: request,
255
+ ...(signal ? { signal } : {}),
256
+ }),
257
+ transportOptions
258
+ );
259
+
260
+ if (error || !data) {
261
+ throw new SyncTransportError(
262
+ `Pull failed: ${getErrorMessage(error) || response.statusText}`,
263
+ response.status
264
+ );
265
+ }
266
+
267
+ return data as SyncPullResponse;
268
+ },
269
+
270
+ async push(
271
+ request: SyncPushRequest,
272
+ transportOptions?: SyncTransportOptions
273
+ ): Promise<SyncPushResponse> {
274
+ const { data, error, response } = await executeWithAuthRetry(
275
+ (signal) =>
276
+ client.POST('/sync/push', {
277
+ body: request,
278
+ ...(signal ? { signal } : {}),
279
+ }),
280
+ transportOptions
281
+ );
282
+
283
+ if (error || !data) {
284
+ throw new SyncTransportError(
285
+ `Push failed: ${getErrorMessage(error) || response.statusText}`,
286
+ response.status
287
+ );
288
+ }
289
+
290
+ return data as SyncPushResponse;
291
+ },
292
+
293
+ async fetchSnapshotChunk(
294
+ request: { chunkId: string },
295
+ transportOptions?: SyncTransportOptions
296
+ ): Promise<Uint8Array> {
297
+ const { data, error, response } = await executeWithAuthRetry(
298
+ (signal) =>
299
+ client.GET('/sync/snapshot-chunks/{chunkId}', {
300
+ params: { path: { chunkId: request.chunkId } },
301
+ parseAs: 'blob',
302
+ ...(signal ? { signal } : {}),
303
+ }),
304
+ transportOptions
305
+ );
306
+
307
+ if (error || !data) {
308
+ throw new SyncTransportError(
309
+ `Snapshot chunk download failed: ${getErrorMessage(error) || response.statusText}`,
310
+ response.status
311
+ );
312
+ }
313
+
314
+ return new Uint8Array(await (data as Blob).arrayBuffer());
315
+ },
316
+
317
+ // Include blob operations
318
+ blobs,
319
+ };
320
+ }