@syncular/transport-http 0.0.1-100

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