@giveitsmaller/sdk 0.2.1 → 0.2.3

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/client.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { UploadResponse, WorkflowCreateResponse, WorkflowStatusResponse, WorkflowDownloadResponse, MetadataResponse, OperationsSchemaResponse, RetryResponse } from '@giveitsmaller/contracts/openapi';
2
2
  import type { GislClientConfig, GislSseEvent, UploadOptions, WaitOptions, WorkflowCreatePayload } from './types.js';
3
+ export declare const DEFAULT_MULTIPART_FIRST_CHUNK_SIZE: number;
3
4
  export declare class GislClient {
4
5
  private readonly baseUrl;
5
6
  private readonly headers;
@@ -18,6 +19,17 @@ export declare class GislClient {
18
19
  */
19
20
  uploadFile(file: string | Blob, options?: UploadOptions): Promise<UploadResponse>;
20
21
  private singleUpload;
22
+ /**
23
+ * Direct-to-S3 multipart upload for files above the threshold.
24
+ *
25
+ * The /multipart/complete response (MultipartCompleteResponse) only carries
26
+ * { upload_id, status }. The server's upload_id is the same UUID callers
27
+ * pass as file_id to POST /api/workflows — so fileId is synthesised from
28
+ * upload_id and a full UploadResponse is returned to keep the public
29
+ * uploadFile() API uniform across single and multipart paths. The mimeType
30
+ * comes from the initiate response's first-chunk detection; for authoritative
31
+ * post-upload metadata callers should use getMetadata(fileId).
32
+ */
21
33
  private multipartUpload;
22
34
  /**
23
35
  * Create a new workflow.
package/dist/client.js CHANGED
@@ -1,11 +1,17 @@
1
1
  import { readFileSync, statSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
- import { UploadResponseFromJSON, MultipartInitiateResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, } from '@giveitsmaller/contracts/openapi';
3
+ import { UploadResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartCompleteResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, } from '@giveitsmaller/contracts/openapi';
4
4
  import { GislApiError, GislError, GislTimeoutError, GislValidationError } from './errors.js';
5
5
  import { parseSseStream } from './sse.js';
6
6
  const DEFAULT_TIMEOUT_MS = 30_000;
7
7
  const DEFAULT_MULTIPART_THRESHOLD = 10 * 1024 * 1024; // 10 MB
8
8
  const DEFAULT_MULTIPART_CONCURRENCY = 4;
9
+ // Fixed per contract (compression_contracts/openapi/api.yaml:134). The server
10
+ // uses the first chunk for MIME detection + throughput measurement and stores
11
+ // it as S3 multipart part 1. Must NOT be derived from multipartThreshold —
12
+ // that is the "use multipart above this size" routing threshold, a separate
13
+ // concept. Conflating them caused the /api/uploads/multipart/initiate 413.
14
+ export const DEFAULT_MULTIPART_FIRST_CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
9
15
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
10
16
  const DEFAULT_POLL_TIMEOUT_MS = 300_000; // 5 min
11
17
  const TERMINAL_STATUSES = new Set([
@@ -13,6 +19,13 @@ const TERMINAL_STATUSES = new Set([
13
19
  WorkflowStatus.failed,
14
20
  WorkflowStatus.partially_failed,
15
21
  ]);
22
+ function isValidationDetails(value) {
23
+ return (Array.isArray(value) &&
24
+ value.every((el) => typeof el === 'object' &&
25
+ el !== null &&
26
+ typeof el.field === 'string' &&
27
+ typeof el.message === 'string'));
28
+ }
16
29
  export class GislClient {
17
30
  baseUrl;
18
31
  headers;
@@ -22,7 +35,10 @@ export class GislClient {
22
35
  constructor(config) {
23
36
  this.baseUrl = config.baseUrl.replace(/\/+$/, '');
24
37
  this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
25
- this.multipartThreshold = config.multipartThreshold ?? DEFAULT_MULTIPART_THRESHOLD;
38
+ // Floor the threshold at the first-chunk size: the multipart initiate
39
+ // must always carry an 8MB chunk, so routing a sub-8MB file into the
40
+ // multipart path would violate the contract.
41
+ this.multipartThreshold = Math.max(config.multipartThreshold ?? DEFAULT_MULTIPART_THRESHOLD, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
26
42
  this.multipartConcurrency = config.multipartConcurrency ?? DEFAULT_MULTIPART_CONCURRENCY;
27
43
  this.headers = { ...config.headers };
28
44
  if (config.apiKey) {
@@ -69,27 +85,34 @@ export class GislClient {
69
85
  return this.handleResponse(response, path, opts.deserialize);
70
86
  }
71
87
  async handleResponse(response, path, deserialize) {
72
- const contentType = response.headers.get('content-type') ?? '';
73
- if (!contentType.includes('application/json')) {
88
+ const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
89
+ const isJsonContent = contentType.includes('application/json') || contentType.includes('+json');
90
+ if (!isJsonContent) {
74
91
  if (!response.ok) {
75
- throw new GislApiError(response.status, `Non-JSON error from ${path}`);
92
+ throw new GislApiError(response.status, 'Non-JSON response', path);
76
93
  }
77
94
  return undefined;
78
95
  }
79
- const json = await response.json();
96
+ let json;
97
+ try {
98
+ json = await response.json();
99
+ }
100
+ catch {
101
+ throw new GislApiError(response.status, 'Invalid JSON response', path);
102
+ }
80
103
  // Schema endpoint returns raw JSON (no envelope)
81
104
  if (path === '/api/operations/schema') {
82
105
  if (!response.ok) {
83
- throw new GislApiError(response.status, json.error ?? 'Unknown error');
106
+ throw new GislApiError(response.status, json.error ?? 'Unknown error', path);
84
107
  }
85
108
  return deserialize ? deserialize(json) : json;
86
109
  }
87
110
  // Standard envelope: { success, data } or { success, error, details }
88
111
  if (!response.ok || json.success === false) {
89
- if (json.details && Array.isArray(json.details)) {
90
- throw new GislValidationError(response.status, json.error ?? 'Validation error', json.details);
112
+ if (isValidationDetails(json.details)) {
113
+ throw new GislValidationError(response.status, json.error ?? 'Validation error', json.details, path);
91
114
  }
92
- throw new GislApiError(response.status, json.error ?? 'Unknown error');
115
+ throw new GislApiError(response.status, json.error ?? 'Unknown error', path, json.details);
93
116
  }
94
117
  const data = json.data ?? json;
95
118
  return deserialize ? deserialize(data) : data;
@@ -134,14 +157,25 @@ export class GislClient {
134
157
  deserialize: UploadResponseFromJSON,
135
158
  });
136
159
  }
160
+ /**
161
+ * Direct-to-S3 multipart upload for files above the threshold.
162
+ *
163
+ * The /multipart/complete response (MultipartCompleteResponse) only carries
164
+ * { upload_id, status }. The server's upload_id is the same UUID callers
165
+ * pass as file_id to POST /api/workflows — so fileId is synthesised from
166
+ * upload_id and a full UploadResponse is returned to keep the public
167
+ * uploadFile() API uniform across single and multipart paths. The mimeType
168
+ * comes from the initiate response's first-chunk detection; for authoritative
169
+ * post-upload metadata callers should use getMetadata(fileId).
170
+ */
137
171
  async multipartUpload(blob, fileName, totalSize, options) {
138
172
  // Step 1: Initiate with first chunk
139
- const firstChunkSize = Math.min(totalSize, this.multipartThreshold);
173
+ const firstChunkSize = Math.min(totalSize, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
140
174
  const firstChunk = blob.slice(0, firstChunkSize);
141
175
  const initiateForm = new FormData();
142
- initiateForm.append('chunk', firstChunk, fileName);
143
- initiateForm.append('original_name', fileName);
144
- initiateForm.append('total_size_bytes', totalSize.toString());
176
+ initiateForm.append('file', firstChunk, fileName);
177
+ initiateForm.append('filename', fileName);
178
+ initiateForm.append('total_size', totalSize.toString());
145
179
  const initResponse = await this.request('POST', '/api/uploads/multipart/initiate', {
146
180
  body: initiateForm,
147
181
  json: false,
@@ -183,15 +217,27 @@ export class GislClient {
183
217
  }
184
218
  });
185
219
  await Promise.all(workers);
186
- // Step 3: Complete multipart upload
220
+ // Step 3: Complete multipart upload.
187
221
  etags.sort((a, b) => a.part_number - b.part_number);
188
- return this.request('POST', '/api/uploads/multipart/complete', {
222
+ const completeResp = await this.request('POST', '/api/uploads/multipart/complete', {
189
223
  body: {
190
- file_id: initResponse.fileId,
224
+ upload_id: initResponse.uploadId,
191
225
  parts: etags,
192
226
  },
193
- deserialize: UploadResponseFromJSON,
227
+ deserialize: MultipartCompleteResponseFromJSON,
194
228
  });
229
+ // Defensive: the status enum currently has only 'completed', but guard
230
+ // against future expansion so an unexpected terminal state doesn't pass
231
+ // as a successful upload.
232
+ if (completeResp.status !== 'completed') {
233
+ throw new GislError(`Multipart upload completed with unexpected status: ${completeResp.status}`);
234
+ }
235
+ return {
236
+ fileId: completeResp.uploadId,
237
+ originalName: fileName,
238
+ mimeType: initResponse.mimeType,
239
+ sizeBytes: blob.size,
240
+ };
195
241
  }
196
242
  // -----------------------------------------------------------------------
197
243
  // Workflows
@@ -244,9 +290,10 @@ export class GislClient {
244
290
  * Stream SSE events for a workflow. Returns an async iterable.
245
291
  */
246
292
  async streamEvents(workflowId) {
247
- const response = await this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/events`, { rawResponse: true });
293
+ const eventsPath = `/api/workflows/${encodeURIComponent(workflowId)}/events`;
294
+ const response = await this.request('GET', eventsPath, { rawResponse: true });
248
295
  if (!response.ok) {
249
- await this.handleResponse(response, `/api/workflows/${workflowId}/events`);
296
+ await this.handleResponse(response, eventsPath);
250
297
  }
251
298
  return parseSseStream(response);
252
299
  }
package/dist/errors.d.ts CHANGED
@@ -4,7 +4,9 @@ export declare class GislError extends Error {
4
4
  export declare class GislApiError extends GislError {
5
5
  readonly statusCode: number;
6
6
  readonly errorMessage: string;
7
- constructor(statusCode: number, errorMessage: string);
7
+ readonly path?: string;
8
+ readonly details?: unknown;
9
+ constructor(statusCode: number, errorMessage: string, path?: string, details?: unknown);
8
10
  }
9
11
  export declare class GislValidationError extends GislApiError {
10
12
  readonly details: Array<{
@@ -14,7 +16,7 @@ export declare class GislValidationError extends GislApiError {
14
16
  constructor(statusCode: number, errorMessage: string, details: Array<{
15
17
  field: string;
16
18
  message: string;
17
- }>);
19
+ }>, path?: string);
18
20
  }
19
21
  export declare class GislTimeoutError extends GislError {
20
22
  constructor(message: string);
package/dist/errors.js CHANGED
@@ -7,19 +7,24 @@ export class GislError extends Error {
7
7
  export class GislApiError extends GislError {
8
8
  statusCode;
9
9
  errorMessage;
10
- constructor(statusCode, errorMessage) {
11
- super(`API error ${statusCode}: ${errorMessage}`);
10
+ path;
11
+ details;
12
+ constructor(statusCode, errorMessage, path, details) {
13
+ const prefix = path
14
+ ? `API error ${statusCode} at ${path}`
15
+ : `API error ${statusCode}`;
16
+ super(`${prefix}: ${errorMessage}`);
12
17
  this.name = 'GislApiError';
13
18
  this.statusCode = statusCode;
14
19
  this.errorMessage = errorMessage;
20
+ this.path = path;
21
+ this.details = details;
15
22
  }
16
23
  }
17
24
  export class GislValidationError extends GislApiError {
18
- details;
19
- constructor(statusCode, errorMessage, details) {
20
- super(statusCode, errorMessage);
25
+ constructor(statusCode, errorMessage, details, path) {
26
+ super(statusCode, errorMessage, path, details);
21
27
  this.name = 'GislValidationError';
22
- this.details = details;
23
28
  }
24
29
  }
25
30
  export class GislTimeoutError extends GislError {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { GislClient } from './client.js';
1
+ export { GislClient, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE } from './client.js';
2
2
  export { verifyWebhook } from './webhook.js';
3
3
  export { parseSseStream } from './sse.js';
4
4
  export type { GislClientConfig, GislSseEvent, UploadOptions, WaitOptions, WorkflowCreatePayload, OperationDef, FileJobPayload, SourceJobPayload, InputsJobPayload, JobDefinitionPayload, } from './types.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // SDK classes and functions
2
- export { GislClient } from './client.js';
2
+ export { GislClient, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE } from './client.js';
3
3
  export { verifyWebhook } from './webhook.js';
4
4
  export { parseSseStream } from './sse.js';
5
5
  export { fileJob, sourceJob, inputsJob } from './types.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giveitsmaller/sdk",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Node.js SDK for the GISL (Give It Smaller) file compression and processing API",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,12 +19,13 @@
19
19
  "node": ">=18"
20
20
  },
21
21
  "dependencies": {
22
- "@giveitsmaller/contracts": "^0.1.0"
22
+ "@giveitsmaller/contracts": "^0.2.3"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^22",
26
26
  "typescript": "^5.7",
27
- "vitest": "^3.1"
27
+ "vitest": "^3.1",
28
+ "yaml": "^2.6"
28
29
  },
29
30
  "scripts": {
30
31
  "build": "tsc",