@giveitsmaller/sdk 0.1.0
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/package.json +29 -0
- package/src/client.ts +401 -0
- package/src/errors.ts +39 -0
- package/src/index.ts +86 -0
- package/src/sse.ts +105 -0
- package/src/types.ts +151 -0
- package/src/webhook.ts +48 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@giveitsmaller/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Node.js SDK for the GISL (Give It Smaller) file compression and processing API",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"src/"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@giveitsmaller/contracts": "file:../../generated/typescript"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22",
|
|
22
|
+
"typescript": "^5.7",
|
|
23
|
+
"vitest": "^3.1"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"check": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
UploadResponseFromJSON,
|
|
6
|
+
MultipartInitiateResponseFromJSON,
|
|
7
|
+
WorkflowCreateResponseFromJSON,
|
|
8
|
+
WorkflowStatusResponseFromJSON,
|
|
9
|
+
WorkflowDownloadResponseFromJSON,
|
|
10
|
+
MetadataResponseFromJSON,
|
|
11
|
+
OperationsSchemaResponseFromJSON,
|
|
12
|
+
RetryResponseFromJSON,
|
|
13
|
+
WorkflowStatus,
|
|
14
|
+
} from '@giveitsmaller/contracts/openapi';
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
UploadResponse,
|
|
18
|
+
MultipartInitiateResponse,
|
|
19
|
+
WorkflowCreateResponse,
|
|
20
|
+
WorkflowStatusResponse,
|
|
21
|
+
WorkflowDownloadResponse,
|
|
22
|
+
MetadataResponse,
|
|
23
|
+
OperationsSchemaResponse,
|
|
24
|
+
RetryResponse,
|
|
25
|
+
} from '@giveitsmaller/contracts/openapi';
|
|
26
|
+
|
|
27
|
+
import { GislApiError, GislError, GislTimeoutError, GislValidationError } from './errors.js';
|
|
28
|
+
import { parseSseStream } from './sse.js';
|
|
29
|
+
import type {
|
|
30
|
+
GislClientConfig,
|
|
31
|
+
GislSseEvent,
|
|
32
|
+
UploadOptions,
|
|
33
|
+
WaitOptions,
|
|
34
|
+
WorkflowCreatePayload,
|
|
35
|
+
} from './types.js';
|
|
36
|
+
|
|
37
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
38
|
+
const DEFAULT_MULTIPART_THRESHOLD = 10 * 1024 * 1024; // 10 MB
|
|
39
|
+
const DEFAULT_MULTIPART_CONCURRENCY = 4;
|
|
40
|
+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
41
|
+
const DEFAULT_POLL_TIMEOUT_MS = 300_000; // 5 min
|
|
42
|
+
|
|
43
|
+
const TERMINAL_STATUSES: ReadonlySet<string> = new Set([
|
|
44
|
+
WorkflowStatus.completed,
|
|
45
|
+
WorkflowStatus.failed,
|
|
46
|
+
WorkflowStatus.partially_failed,
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
export class GislClient {
|
|
50
|
+
private readonly baseUrl: string;
|
|
51
|
+
private readonly headers: Record<string, string>;
|
|
52
|
+
private readonly timeoutMs: number;
|
|
53
|
+
private readonly multipartThreshold: number;
|
|
54
|
+
private readonly multipartConcurrency: number;
|
|
55
|
+
|
|
56
|
+
constructor(config: GislClientConfig) {
|
|
57
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
58
|
+
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
59
|
+
this.multipartThreshold = config.multipartThreshold ?? DEFAULT_MULTIPART_THRESHOLD;
|
|
60
|
+
this.multipartConcurrency = config.multipartConcurrency ?? DEFAULT_MULTIPART_CONCURRENCY;
|
|
61
|
+
|
|
62
|
+
this.headers = { ...config.headers };
|
|
63
|
+
if (config.apiKey) {
|
|
64
|
+
this.headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// -----------------------------------------------------------------------
|
|
69
|
+
// Internal HTTP
|
|
70
|
+
// -----------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
private async request<T>(
|
|
73
|
+
method: string,
|
|
74
|
+
path: string,
|
|
75
|
+
opts: {
|
|
76
|
+
body?: BodyInit | Record<string, unknown>;
|
|
77
|
+
json?: boolean;
|
|
78
|
+
deserialize?: (raw: unknown) => T;
|
|
79
|
+
rawResponse?: boolean;
|
|
80
|
+
} = {},
|
|
81
|
+
): Promise<T> {
|
|
82
|
+
const url = `${this.baseUrl}${path}`;
|
|
83
|
+
const headers: Record<string, string> = { ...this.headers };
|
|
84
|
+
let body: BodyInit | undefined;
|
|
85
|
+
|
|
86
|
+
if (opts.json !== false && opts.body && !(opts.body instanceof FormData)) {
|
|
87
|
+
headers['Content-Type'] = 'application/json';
|
|
88
|
+
body = JSON.stringify(opts.body);
|
|
89
|
+
} else {
|
|
90
|
+
body = opts.body as BodyInit | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
95
|
+
|
|
96
|
+
let response: Response;
|
|
97
|
+
try {
|
|
98
|
+
response = await fetch(url, {
|
|
99
|
+
method,
|
|
100
|
+
headers,
|
|
101
|
+
body,
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
} catch (err: unknown) {
|
|
105
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
106
|
+
throw new GislTimeoutError(`Request to ${method} ${path} timed out after ${this.timeoutMs}ms`);
|
|
107
|
+
}
|
|
108
|
+
throw err;
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (opts.rawResponse) {
|
|
114
|
+
return response as unknown as T;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return this.handleResponse(response, path, opts.deserialize);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async handleResponse<T>(
|
|
121
|
+
response: Response,
|
|
122
|
+
path: string,
|
|
123
|
+
deserialize?: (raw: unknown) => T,
|
|
124
|
+
): Promise<T> {
|
|
125
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
126
|
+
if (!contentType.includes('application/json')) {
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
throw new GislApiError(response.status, `Non-JSON error from ${path}`);
|
|
129
|
+
}
|
|
130
|
+
return undefined as unknown as T;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const json = await response.json();
|
|
134
|
+
|
|
135
|
+
// Schema endpoint returns raw JSON (no envelope)
|
|
136
|
+
if (path === '/api/operations/schema') {
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new GislApiError(response.status, json.error ?? 'Unknown error');
|
|
139
|
+
}
|
|
140
|
+
return deserialize ? deserialize(json) : (json as T);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Standard envelope: { success, data } or { success, error, details }
|
|
144
|
+
if (!response.ok || json.success === false) {
|
|
145
|
+
if (json.details && Array.isArray(json.details)) {
|
|
146
|
+
throw new GislValidationError(response.status, json.error ?? 'Validation error', json.details);
|
|
147
|
+
}
|
|
148
|
+
throw new GislApiError(response.status, json.error ?? 'Unknown error');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const data = json.data ?? json;
|
|
152
|
+
return deserialize ? deserialize(data) : (data as T);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// -----------------------------------------------------------------------
|
|
156
|
+
// Upload
|
|
157
|
+
// -----------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Upload a file. Automatically uses multipart upload for files exceeding
|
|
161
|
+
* the configured threshold (default 10 MB).
|
|
162
|
+
*
|
|
163
|
+
* @param file File path (string) or a Blob/File instance.
|
|
164
|
+
* @param options Upload options including progress callback.
|
|
165
|
+
*/
|
|
166
|
+
async uploadFile(
|
|
167
|
+
file: string | Blob,
|
|
168
|
+
options?: UploadOptions,
|
|
169
|
+
): Promise<UploadResponse> {
|
|
170
|
+
let blob: Blob;
|
|
171
|
+
let fileName: string;
|
|
172
|
+
let fileSize: number;
|
|
173
|
+
|
|
174
|
+
if (typeof file === 'string') {
|
|
175
|
+
const stat = statSync(file);
|
|
176
|
+
fileSize = stat.size;
|
|
177
|
+
fileName = basename(file);
|
|
178
|
+
const content = readFileSync(file);
|
|
179
|
+
blob = new Blob([content]);
|
|
180
|
+
} else {
|
|
181
|
+
blob = file;
|
|
182
|
+
fileName = (file as File).name ?? 'upload';
|
|
183
|
+
fileSize = file.size;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (fileSize > this.multipartThreshold) {
|
|
187
|
+
return this.multipartUpload(blob, fileName, fileSize, options);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return this.singleUpload(blob, fileName);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async singleUpload(blob: Blob, fileName: string): Promise<UploadResponse> {
|
|
194
|
+
const form = new FormData();
|
|
195
|
+
form.append('file', blob, fileName);
|
|
196
|
+
|
|
197
|
+
return this.request('POST', '/api/uploads', {
|
|
198
|
+
body: form,
|
|
199
|
+
json: false,
|
|
200
|
+
deserialize: UploadResponseFromJSON,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async multipartUpload(
|
|
205
|
+
blob: Blob,
|
|
206
|
+
fileName: string,
|
|
207
|
+
totalSize: number,
|
|
208
|
+
options?: UploadOptions,
|
|
209
|
+
): Promise<UploadResponse> {
|
|
210
|
+
// Step 1: Initiate with first chunk
|
|
211
|
+
const firstChunkSize = Math.min(totalSize, this.multipartThreshold);
|
|
212
|
+
const firstChunk = blob.slice(0, firstChunkSize);
|
|
213
|
+
|
|
214
|
+
const initiateForm = new FormData();
|
|
215
|
+
initiateForm.append('chunk', firstChunk, fileName);
|
|
216
|
+
initiateForm.append('original_name', fileName);
|
|
217
|
+
initiateForm.append('total_size_bytes', totalSize.toString());
|
|
218
|
+
|
|
219
|
+
const initResponse = await this.request<MultipartInitiateResponse>(
|
|
220
|
+
'POST',
|
|
221
|
+
'/api/uploads/multipart/initiate',
|
|
222
|
+
{
|
|
223
|
+
body: initiateForm,
|
|
224
|
+
json: false,
|
|
225
|
+
deserialize: MultipartInitiateResponseFromJSON,
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
let uploadedBytes = firstChunkSize;
|
|
230
|
+
options?.onProgress?.(uploadedBytes, totalSize);
|
|
231
|
+
|
|
232
|
+
// Step 2: Upload remaining chunks to S3 presigned URLs
|
|
233
|
+
const etags: Array<{ part_number: number; etag: string }> = [];
|
|
234
|
+
const presignedUrls = initResponse.presignedUrls;
|
|
235
|
+
const chunkSize = initResponse.recommendedChunkSize;
|
|
236
|
+
|
|
237
|
+
const uploadChunk = async (index: number): Promise<void> => {
|
|
238
|
+
const part = presignedUrls[index];
|
|
239
|
+
const start = firstChunkSize + index * chunkSize;
|
|
240
|
+
const end = Math.min(start + chunkSize, totalSize);
|
|
241
|
+
const chunk = blob.slice(start, end);
|
|
242
|
+
|
|
243
|
+
const s3Response = await fetch(part.url, {
|
|
244
|
+
method: 'PUT',
|
|
245
|
+
body: chunk,
|
|
246
|
+
headers: { 'Content-Length': (end - start).toString() },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (!s3Response.ok) {
|
|
250
|
+
throw new GislError(`S3 chunk upload failed for part ${part.partNumber}: ${s3Response.status}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const etag = s3Response.headers.get('etag');
|
|
254
|
+
if (!etag) {
|
|
255
|
+
throw new GislError(`S3 response missing ETag for part ${part.partNumber}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
etags.push({ part_number: part.partNumber, etag });
|
|
259
|
+
uploadedBytes += end - start;
|
|
260
|
+
options?.onProgress?.(uploadedBytes, totalSize);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Upload with concurrency limit
|
|
264
|
+
const queue = [...presignedUrls.keys()];
|
|
265
|
+
const workers = Array.from(
|
|
266
|
+
{ length: Math.min(this.multipartConcurrency, queue.length) },
|
|
267
|
+
async () => {
|
|
268
|
+
while (queue.length > 0) {
|
|
269
|
+
const index = queue.shift()!;
|
|
270
|
+
await uploadChunk(index);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
await Promise.all(workers);
|
|
275
|
+
|
|
276
|
+
// Step 3: Complete multipart upload
|
|
277
|
+
etags.sort((a, b) => a.part_number - b.part_number);
|
|
278
|
+
|
|
279
|
+
return this.request<UploadResponse>('POST', '/api/uploads/multipart/complete', {
|
|
280
|
+
body: {
|
|
281
|
+
file_id: initResponse.fileId,
|
|
282
|
+
parts: etags,
|
|
283
|
+
},
|
|
284
|
+
deserialize: UploadResponseFromJSON,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// -----------------------------------------------------------------------
|
|
289
|
+
// Workflows
|
|
290
|
+
// -----------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Create a new workflow.
|
|
294
|
+
*/
|
|
295
|
+
async createWorkflow(payload: WorkflowCreatePayload): Promise<WorkflowCreateResponse> {
|
|
296
|
+
return this.request('POST', '/api/workflows', {
|
|
297
|
+
body: payload as unknown as Record<string, unknown>,
|
|
298
|
+
deserialize: WorkflowCreateResponseFromJSON,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get current workflow status.
|
|
304
|
+
*/
|
|
305
|
+
async getWorkflowStatus(workflowId: string): Promise<WorkflowStatusResponse> {
|
|
306
|
+
return this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/status`, {
|
|
307
|
+
deserialize: WorkflowStatusResponseFromJSON,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Poll until the workflow reaches a terminal status.
|
|
313
|
+
*/
|
|
314
|
+
async waitForWorkflow(
|
|
315
|
+
workflowId: string,
|
|
316
|
+
options?: WaitOptions,
|
|
317
|
+
): Promise<WorkflowStatusResponse> {
|
|
318
|
+
const intervalMs = options?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
319
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
|
|
320
|
+
const deadline = Date.now() + timeoutMs;
|
|
321
|
+
|
|
322
|
+
while (true) {
|
|
323
|
+
const status = await this.getWorkflowStatus(workflowId);
|
|
324
|
+
options?.onPoll?.(status.status);
|
|
325
|
+
|
|
326
|
+
if (TERMINAL_STATUSES.has(status.status)) {
|
|
327
|
+
return status;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (Date.now() + intervalMs > deadline) {
|
|
331
|
+
throw new GislTimeoutError(
|
|
332
|
+
`Workflow ${workflowId} did not complete within ${timeoutMs}ms`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Get download URLs for a completed workflow.
|
|
342
|
+
*/
|
|
343
|
+
async getWorkflowDownloads(workflowId: string): Promise<WorkflowDownloadResponse> {
|
|
344
|
+
return this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/download`, {
|
|
345
|
+
deserialize: WorkflowDownloadResponseFromJSON,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Stream SSE events for a workflow. Returns an async iterable.
|
|
351
|
+
*/
|
|
352
|
+
async streamEvents(workflowId: string): Promise<AsyncGenerator<GislSseEvent>> {
|
|
353
|
+
const response = await this.request<Response>(
|
|
354
|
+
'GET',
|
|
355
|
+
`/api/workflows/${encodeURIComponent(workflowId)}/events`,
|
|
356
|
+
{ rawResponse: true },
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
if (!response.ok) {
|
|
360
|
+
await this.handleResponse(response, `/api/workflows/${workflowId}/events`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return parseSseStream(response);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// -----------------------------------------------------------------------
|
|
367
|
+
// File metadata
|
|
368
|
+
// -----------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Get metadata for an uploaded file.
|
|
372
|
+
*/
|
|
373
|
+
async getMetadata(fileId: string): Promise<MetadataResponse> {
|
|
374
|
+
return this.request('GET', `/api/uploads/${encodeURIComponent(fileId)}/metadata`, {
|
|
375
|
+
deserialize: MetadataResponseFromJSON,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// -----------------------------------------------------------------------
|
|
380
|
+
// Operations
|
|
381
|
+
// -----------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Get the operations schema (available types, options, constraints).
|
|
385
|
+
* This endpoint returns raw JSON (no envelope) and is CDN-cacheable.
|
|
386
|
+
*/
|
|
387
|
+
async getSchema(): Promise<OperationsSchemaResponse> {
|
|
388
|
+
return this.request('GET', '/api/operations/schema', {
|
|
389
|
+
deserialize: OperationsSchemaResponseFromJSON,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Retry a failed operation.
|
|
395
|
+
*/
|
|
396
|
+
async retryOperation(operationId: string): Promise<RetryResponse> {
|
|
397
|
+
return this.request('POST', `/api/operations/${encodeURIComponent(operationId)}/retry`, {
|
|
398
|
+
deserialize: RetryResponseFromJSON,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export class GislError extends Error {
|
|
2
|
+
constructor(message: string) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'GislError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class GislApiError extends GislError {
|
|
9
|
+
readonly statusCode: number;
|
|
10
|
+
readonly errorMessage: string;
|
|
11
|
+
|
|
12
|
+
constructor(statusCode: number, errorMessage: string) {
|
|
13
|
+
super(`API error ${statusCode}: ${errorMessage}`);
|
|
14
|
+
this.name = 'GislApiError';
|
|
15
|
+
this.statusCode = statusCode;
|
|
16
|
+
this.errorMessage = errorMessage;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class GislValidationError extends GislApiError {
|
|
21
|
+
readonly details: Array<{ field: string; message: string }>;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
statusCode: number,
|
|
25
|
+
errorMessage: string,
|
|
26
|
+
details: Array<{ field: string; message: string }>,
|
|
27
|
+
) {
|
|
28
|
+
super(statusCode, errorMessage);
|
|
29
|
+
this.name = 'GislValidationError';
|
|
30
|
+
this.details = details;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class GislTimeoutError extends GislError {
|
|
35
|
+
constructor(message: string) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'GislTimeoutError';
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SDK classes and functions
|
|
2
|
+
export { GislClient } from './client.js';
|
|
3
|
+
export { verifyWebhook } from './webhook.js';
|
|
4
|
+
export { parseSseStream } from './sse.js';
|
|
5
|
+
|
|
6
|
+
// SDK types and factories
|
|
7
|
+
export type {
|
|
8
|
+
GislClientConfig,
|
|
9
|
+
GislSseEvent,
|
|
10
|
+
UploadOptions,
|
|
11
|
+
WaitOptions,
|
|
12
|
+
WorkflowCreatePayload,
|
|
13
|
+
OperationDef,
|
|
14
|
+
FileJobPayload,
|
|
15
|
+
SourceJobPayload,
|
|
16
|
+
InputsJobPayload,
|
|
17
|
+
JobDefinitionPayload,
|
|
18
|
+
} from './types.js';
|
|
19
|
+
export { fileJob, sourceJob, inputsJob } from './types.js';
|
|
20
|
+
|
|
21
|
+
// Errors
|
|
22
|
+
export {
|
|
23
|
+
GislError,
|
|
24
|
+
GislApiError,
|
|
25
|
+
GislValidationError,
|
|
26
|
+
GislTimeoutError,
|
|
27
|
+
} from './errors.js';
|
|
28
|
+
|
|
29
|
+
// Re-export key contract types so users only need @giveitsmaller/sdk
|
|
30
|
+
export type {
|
|
31
|
+
UploadResponse,
|
|
32
|
+
WorkflowCreateResponse,
|
|
33
|
+
WorkflowStatusResponse,
|
|
34
|
+
WorkflowDownloadResponse,
|
|
35
|
+
MetadataResponse,
|
|
36
|
+
OperationsSchemaResponse,
|
|
37
|
+
RetryResponse,
|
|
38
|
+
JobDownload,
|
|
39
|
+
OperationDownload,
|
|
40
|
+
WebhookPayload,
|
|
41
|
+
JobResponse,
|
|
42
|
+
OperationResponse,
|
|
43
|
+
OperationResult,
|
|
44
|
+
ExportConfig,
|
|
45
|
+
} from '@giveitsmaller/contracts/openapi';
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
WorkflowStatus,
|
|
49
|
+
OperationType,
|
|
50
|
+
SseEventType,
|
|
51
|
+
CallbackEventType,
|
|
52
|
+
OperationStatus,
|
|
53
|
+
JobStatus,
|
|
54
|
+
} from '@giveitsmaller/contracts/openapi';
|
|
55
|
+
|
|
56
|
+
export type {
|
|
57
|
+
SseOperationProgressData,
|
|
58
|
+
SseOperationCompletedData,
|
|
59
|
+
SseOperationFailedData,
|
|
60
|
+
SseJobCompletedData,
|
|
61
|
+
SseJobFailedData,
|
|
62
|
+
SseWorkflowTerminalData,
|
|
63
|
+
} from '@giveitsmaller/contracts/openapi';
|
|
64
|
+
|
|
65
|
+
// Re-export all operation option types
|
|
66
|
+
export type {
|
|
67
|
+
CompressImageOptions,
|
|
68
|
+
CompressVideoOptions,
|
|
69
|
+
CompressAudioOptions,
|
|
70
|
+
CompressDocumentPdfOptions,
|
|
71
|
+
CompressDocumentOfficeOptions,
|
|
72
|
+
CompressDocumentOdfOptions,
|
|
73
|
+
CompressDocumentEpubOptions,
|
|
74
|
+
ThumbnailImageOptions,
|
|
75
|
+
ThumbnailVideoOptions,
|
|
76
|
+
ThumbnailDocumentOptions,
|
|
77
|
+
ConvertImageOptions,
|
|
78
|
+
ConvertVideoOptions,
|
|
79
|
+
ConvertAudioOptions,
|
|
80
|
+
ConvertDocumentPdfOptions,
|
|
81
|
+
MergeImageOptions,
|
|
82
|
+
MergeVideoOptions,
|
|
83
|
+
MergeAudioOptions,
|
|
84
|
+
MergeDocumentOptions,
|
|
85
|
+
ArchiveOptions,
|
|
86
|
+
} from '@giveitsmaller/contracts/operations';
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { GislSseEvent } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse an SSE stream from a fetch Response into an AsyncIterable of typed events.
|
|
5
|
+
*
|
|
6
|
+
* Handles:
|
|
7
|
+
* - Chunk boundary buffering (events split across chunks)
|
|
8
|
+
* - Multi-line `data:` fields (concatenated with newlines)
|
|
9
|
+
* - Comment lines (`:` prefix) used as keep-alives
|
|
10
|
+
* - `retry:` field (ignored, SDK manages its own reconnection)
|
|
11
|
+
*/
|
|
12
|
+
export async function* parseSseStream(
|
|
13
|
+
response: Response,
|
|
14
|
+
): AsyncGenerator<GislSseEvent> {
|
|
15
|
+
const body = response.body;
|
|
16
|
+
if (!body) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const reader = body.getReader();
|
|
21
|
+
const decoder = new TextDecoder();
|
|
22
|
+
let buffer = '';
|
|
23
|
+
let eventType = '';
|
|
24
|
+
let dataLines: string[] = [];
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
while (true) {
|
|
28
|
+
const { done, value } = await reader.read();
|
|
29
|
+
if (done) break;
|
|
30
|
+
|
|
31
|
+
buffer += decoder.decode(value, { stream: true });
|
|
32
|
+
|
|
33
|
+
// SSE spec allows \n, \r\n, and \r as line endings
|
|
34
|
+
const lines = buffer.replace(/\r\n?/g, '\n').split('\n');
|
|
35
|
+
// Keep the last (potentially incomplete) line in the buffer
|
|
36
|
+
buffer = lines.pop() ?? '';
|
|
37
|
+
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (line === '') {
|
|
40
|
+
// Empty line = end of event
|
|
41
|
+
if (dataLines.length > 0) {
|
|
42
|
+
const rawData = dataLines.join('\n');
|
|
43
|
+
let parsed: unknown;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(rawData);
|
|
46
|
+
} catch {
|
|
47
|
+
parsed = rawData;
|
|
48
|
+
}
|
|
49
|
+
yield {
|
|
50
|
+
event: eventType || 'message',
|
|
51
|
+
data: parsed,
|
|
52
|
+
} as GislSseEvent;
|
|
53
|
+
}
|
|
54
|
+
eventType = '';
|
|
55
|
+
dataLines = [];
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (line.startsWith(':')) {
|
|
60
|
+
// Comment line (keep-alive), skip
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const colonIndex = line.indexOf(':');
|
|
65
|
+
if (colonIndex === -1) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const field = line.slice(0, colonIndex);
|
|
70
|
+
// Strip single leading space after colon per SSE spec
|
|
71
|
+
const valueStart = line[colonIndex + 1] === ' ' ? colonIndex + 2 : colonIndex + 1;
|
|
72
|
+
const fieldValue = line.slice(valueStart);
|
|
73
|
+
|
|
74
|
+
switch (field) {
|
|
75
|
+
case 'event':
|
|
76
|
+
eventType = fieldValue;
|
|
77
|
+
break;
|
|
78
|
+
case 'data':
|
|
79
|
+
dataLines.push(fieldValue);
|
|
80
|
+
break;
|
|
81
|
+
case 'retry':
|
|
82
|
+
// Ignored — SDK manages its own polling/reconnection
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Flush any remaining buffered event
|
|
89
|
+
if (dataLines.length > 0) {
|
|
90
|
+
const rawData = dataLines.join('\n');
|
|
91
|
+
let parsed: unknown;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(rawData);
|
|
94
|
+
} catch {
|
|
95
|
+
parsed = rawData;
|
|
96
|
+
}
|
|
97
|
+
yield {
|
|
98
|
+
event: eventType || 'message',
|
|
99
|
+
data: parsed,
|
|
100
|
+
} as GislSseEvent;
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
reader.releaseLock();
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
OperationType,
|
|
3
|
+
CallbackEventType,
|
|
4
|
+
SseEventType,
|
|
5
|
+
SseOperationProgressData,
|
|
6
|
+
SseOperationCompletedData,
|
|
7
|
+
SseOperationFailedData,
|
|
8
|
+
SseJobCompletedData,
|
|
9
|
+
SseJobFailedData,
|
|
10
|
+
SseWorkflowTerminalData,
|
|
11
|
+
} from '@giveitsmaller/contracts/openapi';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Client configuration
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export interface GislClientConfig {
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
apiKey?: string;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
timeout?: number;
|
|
22
|
+
/** Threshold in bytes above which multipart upload is used (default: 10MB) */
|
|
23
|
+
multipartThreshold?: number;
|
|
24
|
+
/** Max concurrent chunk uploads for multipart (default: 4) */
|
|
25
|
+
multipartConcurrency?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Job definition (typed replacement for generated `any`)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export interface OperationDef {
|
|
33
|
+
type: OperationType;
|
|
34
|
+
options?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Job sourced from an uploaded file */
|
|
38
|
+
export interface FileJobPayload {
|
|
39
|
+
ref: string;
|
|
40
|
+
file_id: string;
|
|
41
|
+
operations: OperationDef[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Job sourced from a single upstream job's output */
|
|
45
|
+
export interface SourceJobPayload {
|
|
46
|
+
ref: string;
|
|
47
|
+
source: { ref: string; operation?: string };
|
|
48
|
+
operations: OperationDef[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Job sourced from multiple upstream jobs (merge/archive) */
|
|
52
|
+
export interface InputsJobPayload {
|
|
53
|
+
ref: string;
|
|
54
|
+
inputs: Array<{
|
|
55
|
+
ref: string;
|
|
56
|
+
operation?: string;
|
|
57
|
+
per_input_options?: Record<string, unknown>;
|
|
58
|
+
}>;
|
|
59
|
+
operations: OperationDef[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type JobDefinitionPayload =
|
|
63
|
+
| FileJobPayload
|
|
64
|
+
| SourceJobPayload
|
|
65
|
+
| InputsJobPayload;
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Job factory functions
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
export function fileJob(
|
|
72
|
+
ref: string,
|
|
73
|
+
fileId: string,
|
|
74
|
+
operations: OperationDef[],
|
|
75
|
+
): FileJobPayload {
|
|
76
|
+
return { ref, file_id: fileId, operations };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function sourceJob(
|
|
80
|
+
ref: string,
|
|
81
|
+
source: { ref: string; operation?: string },
|
|
82
|
+
operations: OperationDef[],
|
|
83
|
+
): SourceJobPayload {
|
|
84
|
+
return { ref, source, operations };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function inputsJob(
|
|
88
|
+
ref: string,
|
|
89
|
+
inputs: Array<{
|
|
90
|
+
ref: string;
|
|
91
|
+
operation?: string;
|
|
92
|
+
per_input_options?: Record<string, unknown>;
|
|
93
|
+
}>,
|
|
94
|
+
operations: OperationDef[],
|
|
95
|
+
): InputsJobPayload {
|
|
96
|
+
return { ref, inputs, operations };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Workflow creation request (SDK-level, wire-format ready)
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
export interface WorkflowCreatePayload {
|
|
104
|
+
jobs: JobDefinitionPayload[];
|
|
105
|
+
workflow_edges?: Array<{ from: string; to: string }>;
|
|
106
|
+
callback_url?: string;
|
|
107
|
+
callback_events?: CallbackEventType[];
|
|
108
|
+
export?: {
|
|
109
|
+
service: 's3';
|
|
110
|
+
bucket: string;
|
|
111
|
+
key_prefix?: string;
|
|
112
|
+
role_arn: string;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Polling options
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
export interface WaitOptions {
|
|
121
|
+
/** Poll interval in milliseconds (default: 2000) */
|
|
122
|
+
intervalMs?: number;
|
|
123
|
+
/** Maximum wait time in milliseconds (default: 300000 = 5 min) */
|
|
124
|
+
timeoutMs?: number;
|
|
125
|
+
/** Called after each poll with current status */
|
|
126
|
+
onPoll?: (status: string) => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// SSE event types (SDK-level typed discriminated union)
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
export type GislSseEvent =
|
|
134
|
+
| { event: typeof SseEventType.operation_progress; data: SseOperationProgressData }
|
|
135
|
+
| { event: typeof SseEventType.operation_completed; data: SseOperationCompletedData }
|
|
136
|
+
| { event: typeof SseEventType.operation_failed; data: SseOperationFailedData }
|
|
137
|
+
| { event: typeof SseEventType.job_completed; data: SseJobCompletedData }
|
|
138
|
+
| { event: typeof SseEventType.job_failed; data: SseJobFailedData }
|
|
139
|
+
| { event: typeof SseEventType.workflow_completed; data: SseWorkflowTerminalData }
|
|
140
|
+
| { event: typeof SseEventType.workflow_failed; data: SseWorkflowTerminalData }
|
|
141
|
+
| { event: typeof SseEventType.workflow_partially_failed; data: SseWorkflowTerminalData }
|
|
142
|
+
| { event: string; data: unknown };
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Upload options
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
export interface UploadOptions {
|
|
149
|
+
/** Called with bytes uploaded so far (only for multipart) */
|
|
150
|
+
onProgress?: (uploadedBytes: number, totalBytes: number) => void;
|
|
151
|
+
}
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { GislError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
const SIGNATURE_PREFIX = 'sha256=';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Verify a GISL webhook signature.
|
|
8
|
+
*
|
|
9
|
+
* @param secret The `webhook_secret` from the workflow creation response.
|
|
10
|
+
* @param signature The value of the `X-GIS-Signature` header.
|
|
11
|
+
* @param body The raw request body as a string or Buffer.
|
|
12
|
+
* @returns `true` if valid.
|
|
13
|
+
* @throws {GislError} if the signature is missing, malformed, or invalid.
|
|
14
|
+
*/
|
|
15
|
+
export function verifyWebhook(
|
|
16
|
+
secret: string,
|
|
17
|
+
signature: string,
|
|
18
|
+
body: string | Buffer,
|
|
19
|
+
): boolean {
|
|
20
|
+
if (!signature.startsWith(SIGNATURE_PREFIX)) {
|
|
21
|
+
throw new GislError(
|
|
22
|
+
`Invalid signature format: expected "${SIGNATURE_PREFIX}<hex>"`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const receivedHex = signature.slice(SIGNATURE_PREFIX.length);
|
|
27
|
+
|
|
28
|
+
if (!/^[0-9a-f]{64}$/i.test(receivedHex)) {
|
|
29
|
+
throw new GislError('Webhook signature verification failed');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const expectedHex = createHmac('sha256', secret)
|
|
33
|
+
.update(body)
|
|
34
|
+
.digest('hex');
|
|
35
|
+
|
|
36
|
+
const receivedBuf = Buffer.from(receivedHex, 'hex');
|
|
37
|
+
const expectedBuf = Buffer.from(expectedHex, 'hex');
|
|
38
|
+
|
|
39
|
+
if (receivedBuf.length !== expectedBuf.length) {
|
|
40
|
+
throw new GislError('Webhook signature verification failed');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!timingSafeEqual(receivedBuf, expectedBuf)) {
|
|
44
|
+
throw new GislError('Webhook signature verification failed');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return true;
|
|
48
|
+
}
|