@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
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SyncAuthErrorContext,
|
|
3
|
+
SyncAuthLifecycle,
|
|
4
|
+
SyncCombinedRequest,
|
|
5
|
+
SyncCombinedResponse,
|
|
6
|
+
} from '@syncular/core';
|
|
7
|
+
import type { SyncClient } from './api-client';
|
|
8
|
+
import {
|
|
9
|
+
type ApiResult,
|
|
10
|
+
type ClientOptions,
|
|
11
|
+
encodeSnapshotScopes,
|
|
12
|
+
type ResolveAuthRetry,
|
|
13
|
+
resolveRequestUrl,
|
|
14
|
+
SNAPSHOT_SCOPES_HEADER,
|
|
15
|
+
} from './shared';
|
|
16
|
+
|
|
17
|
+
export type { ClientOptions };
|
|
18
|
+
|
|
19
|
+
export const HTTP_TRANSPORT_SOURCE = Symbol.for(
|
|
20
|
+
'@syncular/transport-http/source'
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export type HttpTransportSource = SyncClient | ClientOptions;
|
|
24
|
+
|
|
25
|
+
function isApiClientLike(value: unknown): value is SyncClient {
|
|
26
|
+
return (
|
|
27
|
+
typeof value === 'object' &&
|
|
28
|
+
value !== null &&
|
|
29
|
+
typeof (value as SyncClient).GET === 'function' &&
|
|
30
|
+
typeof (value as SyncClient).POST === 'function'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TransportApiClient {
|
|
35
|
+
sync(
|
|
36
|
+
request: SyncCombinedRequest,
|
|
37
|
+
signal?: AbortSignal
|
|
38
|
+
): Promise<ApiResult<SyncCombinedResponse>>;
|
|
39
|
+
initiateUpload(
|
|
40
|
+
args: { hash: string; size: number; mimeType: string },
|
|
41
|
+
signal?: AbortSignal
|
|
42
|
+
): Promise<
|
|
43
|
+
ApiResult<{
|
|
44
|
+
exists: boolean;
|
|
45
|
+
uploadUrl?: string;
|
|
46
|
+
uploadMethod?: 'PUT' | 'POST';
|
|
47
|
+
uploadHeaders?: Record<string, string>;
|
|
48
|
+
}>
|
|
49
|
+
>;
|
|
50
|
+
completeUpload(
|
|
51
|
+
hash: string,
|
|
52
|
+
signal?: AbortSignal
|
|
53
|
+
): Promise<ApiResult<{ ok: boolean; error?: string }>>;
|
|
54
|
+
getDownloadUrl(
|
|
55
|
+
hash: string,
|
|
56
|
+
signal?: AbortSignal
|
|
57
|
+
): Promise<ApiResult<{ url: string; expiresAt: string }>>;
|
|
58
|
+
getSnapshotChunk(
|
|
59
|
+
chunkId: string,
|
|
60
|
+
scopeValues: Record<string, string | string[]> | undefined,
|
|
61
|
+
signal?: AbortSignal
|
|
62
|
+
): Promise<ApiResult<Blob>>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isJsonContentType(contentType: string | null): boolean {
|
|
66
|
+
return contentType?.includes('application/json') === true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function parseErrorBody(response: Response): Promise<unknown> {
|
|
70
|
+
if (!isJsonContentType(response.headers.get('content-type'))) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
return (await response.json()) as unknown;
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function parseJsonBody<T>(response: Response): Promise<T | undefined> {
|
|
81
|
+
if (!isJsonContentType(response.headers.get('content-type'))) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return (await response.json()) as T;
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createAuthRetryResolver(
|
|
92
|
+
defaultAuthLifecycle: SyncAuthLifecycle | undefined
|
|
93
|
+
): ResolveAuthRetry {
|
|
94
|
+
let refreshInFlight: Promise<boolean> | null = null;
|
|
95
|
+
|
|
96
|
+
const runRefreshSingleFlight = async (
|
|
97
|
+
lifecycle: SyncAuthLifecycle,
|
|
98
|
+
context: SyncAuthErrorContext
|
|
99
|
+
): Promise<boolean> => {
|
|
100
|
+
if (!lifecycle.refreshToken) return false;
|
|
101
|
+
|
|
102
|
+
if (!refreshInFlight) {
|
|
103
|
+
refreshInFlight = Promise.resolve(lifecycle.refreshToken(context))
|
|
104
|
+
.then((result) => Boolean(result))
|
|
105
|
+
.finally(() => {
|
|
106
|
+
refreshInFlight = null;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return refreshInFlight;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return async (context, options) => {
|
|
114
|
+
if (options?.onAuthError) {
|
|
115
|
+
return Boolean(await options.onAuthError());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lifecycle = options?.authLifecycle ?? defaultAuthLifecycle;
|
|
119
|
+
if (!lifecycle) return false;
|
|
120
|
+
|
|
121
|
+
await lifecycle.onAuthExpired?.(context);
|
|
122
|
+
|
|
123
|
+
const refreshResult = await runRefreshSingleFlight(lifecycle, context);
|
|
124
|
+
if (lifecycle.retryWithFreshToken) {
|
|
125
|
+
return Boolean(
|
|
126
|
+
await lifecycle.retryWithFreshToken({ ...context, refreshResult })
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return refreshResult;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function createRequestHeaders(
|
|
134
|
+
baseOptions: ClientOptions,
|
|
135
|
+
extraHeaders?: Record<string, string>
|
|
136
|
+
): Promise<Headers> {
|
|
137
|
+
return Promise.resolve(baseOptions.getHeaders?.()).then((dynamicHeaders) => {
|
|
138
|
+
const headers = new Headers(extraHeaders);
|
|
139
|
+
if (dynamicHeaders) {
|
|
140
|
+
for (const [key, value] of Object.entries(dynamicHeaders)) {
|
|
141
|
+
headers.set(key, value);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!headers.has('x-syncular-transport-path')) {
|
|
145
|
+
headers.set(
|
|
146
|
+
'x-syncular-transport-path',
|
|
147
|
+
baseOptions.transportPath ?? 'direct'
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return headers;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function createFetchApiClient(baseOptions: ClientOptions): TransportApiClient {
|
|
155
|
+
const fetchImpl = baseOptions.fetch ?? globalThis.fetch;
|
|
156
|
+
|
|
157
|
+
const request = async <T>(args: {
|
|
158
|
+
method: 'GET' | 'POST';
|
|
159
|
+
path: string;
|
|
160
|
+
body?: unknown;
|
|
161
|
+
headers?: Record<string, string>;
|
|
162
|
+
signal?: AbortSignal;
|
|
163
|
+
parseAs?: 'json' | 'blob';
|
|
164
|
+
}): Promise<ApiResult<T>> => {
|
|
165
|
+
const headers = await createRequestHeaders(baseOptions, args.headers);
|
|
166
|
+
let body: BodyInit | undefined;
|
|
167
|
+
if (args.body !== undefined) {
|
|
168
|
+
headers.set('content-type', 'application/json');
|
|
169
|
+
body = JSON.stringify(args.body);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const response = await fetchImpl(
|
|
173
|
+
resolveRequestUrl(baseOptions.baseUrl, args.path),
|
|
174
|
+
{
|
|
175
|
+
method: args.method,
|
|
176
|
+
headers,
|
|
177
|
+
body,
|
|
178
|
+
...(args.signal ? { signal: args.signal } : {}),
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
return {
|
|
184
|
+
response,
|
|
185
|
+
error: await parseErrorBody(response),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (args.parseAs === 'blob') {
|
|
190
|
+
return {
|
|
191
|
+
response,
|
|
192
|
+
data: (await response.blob()) as T,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
response,
|
|
198
|
+
data: await parseJsonBody<T>(response),
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
sync: (requestBody, signal) =>
|
|
204
|
+
request({
|
|
205
|
+
method: 'POST',
|
|
206
|
+
path: '/sync',
|
|
207
|
+
body: requestBody,
|
|
208
|
+
signal,
|
|
209
|
+
}),
|
|
210
|
+
initiateUpload: (args, signal) =>
|
|
211
|
+
request({
|
|
212
|
+
method: 'POST',
|
|
213
|
+
path: '/sync/blobs/upload',
|
|
214
|
+
body: args,
|
|
215
|
+
signal,
|
|
216
|
+
}),
|
|
217
|
+
completeUpload: (hash, signal) =>
|
|
218
|
+
request({
|
|
219
|
+
method: 'POST',
|
|
220
|
+
path: `/sync/blobs/${encodeURIComponent(hash)}/complete`,
|
|
221
|
+
signal,
|
|
222
|
+
}),
|
|
223
|
+
getDownloadUrl: (hash, signal) =>
|
|
224
|
+
request({
|
|
225
|
+
method: 'GET',
|
|
226
|
+
path: `/sync/blobs/${encodeURIComponent(hash)}/url`,
|
|
227
|
+
signal,
|
|
228
|
+
}),
|
|
229
|
+
getSnapshotChunk: (chunkId, scopeValues, signal) =>
|
|
230
|
+
request({
|
|
231
|
+
method: 'GET',
|
|
232
|
+
path: `/sync/snapshot-chunks/${encodeURIComponent(chunkId)}`,
|
|
233
|
+
headers: (() => {
|
|
234
|
+
const encodedScopes = encodeSnapshotScopes(scopeValues);
|
|
235
|
+
return encodedScopes
|
|
236
|
+
? {
|
|
237
|
+
[SNAPSHOT_SCOPES_HEADER]: encodedScopes,
|
|
238
|
+
}
|
|
239
|
+
: undefined;
|
|
240
|
+
})(),
|
|
241
|
+
signal,
|
|
242
|
+
parseAs: 'blob',
|
|
243
|
+
}),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function createTypedTransportClient(client: SyncClient): TransportApiClient {
|
|
248
|
+
return {
|
|
249
|
+
sync: (request, signal) =>
|
|
250
|
+
client.POST('/sync', {
|
|
251
|
+
body: request,
|
|
252
|
+
...(signal ? { signal } : {}),
|
|
253
|
+
}) as Promise<ApiResult<SyncCombinedResponse>>,
|
|
254
|
+
initiateUpload: (args, signal) =>
|
|
255
|
+
client.POST('/sync/blobs/upload', {
|
|
256
|
+
body: args,
|
|
257
|
+
...(signal ? { signal } : {}),
|
|
258
|
+
}) as Promise<
|
|
259
|
+
ApiResult<{
|
|
260
|
+
exists: boolean;
|
|
261
|
+
uploadUrl?: string;
|
|
262
|
+
uploadMethod?: 'PUT' | 'POST';
|
|
263
|
+
uploadHeaders?: Record<string, string>;
|
|
264
|
+
}>
|
|
265
|
+
>,
|
|
266
|
+
completeUpload: (hash, signal) =>
|
|
267
|
+
client.POST('/sync/blobs/{hash}/complete', {
|
|
268
|
+
params: { path: { hash } },
|
|
269
|
+
...(signal ? { signal } : {}),
|
|
270
|
+
}) as Promise<ApiResult<{ ok: boolean; error?: string }>>,
|
|
271
|
+
getDownloadUrl: (hash, signal) =>
|
|
272
|
+
client.GET('/sync/blobs/{hash}/url', {
|
|
273
|
+
params: { path: { hash } },
|
|
274
|
+
...(signal ? { signal } : {}),
|
|
275
|
+
}) as Promise<ApiResult<{ url: string; expiresAt: string }>>,
|
|
276
|
+
getSnapshotChunk: (chunkId, scopeValues, signal) =>
|
|
277
|
+
client.GET('/sync/snapshot-chunks/{chunkId}', {
|
|
278
|
+
params: { path: { chunkId } },
|
|
279
|
+
parseAs: 'blob',
|
|
280
|
+
headers: (() => {
|
|
281
|
+
const encodedScopes = encodeSnapshotScopes(scopeValues);
|
|
282
|
+
return encodedScopes
|
|
283
|
+
? {
|
|
284
|
+
[SNAPSHOT_SCOPES_HEADER]: encodedScopes,
|
|
285
|
+
}
|
|
286
|
+
: undefined;
|
|
287
|
+
})(),
|
|
288
|
+
...(signal ? { signal } : {}),
|
|
289
|
+
}) as Promise<ApiResult<Blob>>,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function createTransportApiClient(
|
|
294
|
+
source: HttpTransportSource
|
|
295
|
+
): TransportApiClient {
|
|
296
|
+
return isApiClientLike(source)
|
|
297
|
+
? createTypedTransportClient(source)
|
|
298
|
+
: createFetchApiClient(source);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function createTransportAuthRetryResolver(
|
|
302
|
+
source: HttpTransportSource
|
|
303
|
+
): ResolveAuthRetry {
|
|
304
|
+
return createAuthRetryResolver(
|
|
305
|
+
isApiClientLike(source) ? undefined : source.authLifecycle
|
|
306
|
+
);
|
|
307
|
+
}
|