@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/dist/generated/api.d.ts +1897 -0
- package/dist/generated/api.d.ts.map +1 -0
- package/dist/generated/api.js +6 -0
- package/dist/generated/api.js.map +1 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +199 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/__tests__/transport-options.test.ts +175 -0
- package/src/generated/api.ts +1897 -0
- package/src/index.ts +320 -0
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
|
+
}
|