@giveitsmaller/sdk 0.2.2 → 0.4.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/README.md +58 -0
- package/dist/_audit.d.ts +1 -0
- package/dist/_audit.js +57 -0
- package/dist/client.d.ts +181 -4
- package/dist/client.js +799 -55
- package/dist/errors.d.ts +66 -9
- package/dist/errors.js +69 -6
- package/dist/index.d.ts +13 -6
- package/dist/index.js +27 -3
- package/dist/types.d.ts +218 -37
- package/dist/types.js +28 -7
- package/package.json +5 -3
package/dist/client.js
CHANGED
|
@@ -1,38 +1,205 @@
|
|
|
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';
|
|
4
|
-
import { GislApiError, GislError, GislTimeoutError, GislValidationError } from './errors.js';
|
|
3
|
+
import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
|
|
4
|
+
import { GislAbortError, GislApiError, GislAuthError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislTierRestrictedError, GislTimeoutError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
|
|
5
5
|
import { parseSseStream } from './sse.js';
|
|
6
6
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// SDK-internal aliases derived from the contract-pinned UploadThresholds enums
|
|
8
|
+
// (compression_contracts/openapi schema `UploadThresholds`, ticket u0ar7Yye).
|
|
9
|
+
// `satisfies number` keeps the literal type so the drift guards below pin the
|
|
10
|
+
// expected value at compile time. Bumping any of these requires a contracts
|
|
11
|
+
// release that regenerates the corresponding *Enum, plus updating the literal
|
|
12
|
+
// in the matching `_AssertTrue<>` line.
|
|
13
|
+
const SINGLE_SHOT_MAX_BYTES = UploadThresholdsSingleShotMaxBytesEnum.NUMBER_10000000;
|
|
14
|
+
const MULTIPART_CHUNK_SIZE = UploadThresholdsMultipartChunkSizeEnum.NUMBER_5242880;
|
|
15
|
+
export const MULTIPART_CONCURRENCY_DEFAULT = UploadThresholdsMultipartConcurrencyDefaultEnum.NUMBER_4;
|
|
16
|
+
const DEFAULT_MULTIPART_MAX_ATTEMPTS = 3;
|
|
17
|
+
const DEFAULT_MULTIPART_RETRY_BASE_MS = 500;
|
|
9
18
|
// Fixed per contract (compression_contracts/openapi/api.yaml:134). The server
|
|
10
19
|
// uses the first chunk for MIME detection + throughput measurement and stores
|
|
11
20
|
// it as S3 multipart part 1. Must NOT be derived from multipartThreshold —
|
|
12
21
|
// that is the "use multipart above this size" routing threshold, a separate
|
|
13
22
|
// concept. Conflating them caused the /api/uploads/multipart/initiate 413.
|
|
23
|
+
// TODO(58nBQLWQ): replace with UploadThresholdsMultipartFirstChunkSizeEnum
|
|
24
|
+
// once contracts ticket promotes this to a typed const (v2.3.1 follow-up).
|
|
14
25
|
export const DEFAULT_MULTIPART_FIRST_CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
|
|
15
26
|
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
16
27
|
const DEFAULT_POLL_TIMEOUT_MS = 300_000; // 5 min
|
|
28
|
+
// Statuses that waitForWorkflow() returns immediately on. Per ticket I24,
|
|
29
|
+
// `cancelled` and `expired` are terminal (a workflow cannot leave either
|
|
30
|
+
// state). `paused_insufficient_credits` is a soft-pause: not terminal, but
|
|
31
|
+
// polling blindly is the wrong behaviour because the workflow only resumes
|
|
32
|
+
// on caller action (top-up + resume). The SDK returns immediately so the
|
|
33
|
+
// caller can inspect `pausedDetail` and drive the resume flow.
|
|
17
34
|
const TERMINAL_STATUSES = new Set([
|
|
18
35
|
WorkflowStatus.completed,
|
|
19
36
|
WorkflowStatus.failed,
|
|
20
37
|
WorkflowStatus.partially_failed,
|
|
38
|
+
WorkflowStatus.cancelled,
|
|
39
|
+
WorkflowStatus.expired,
|
|
40
|
+
WorkflowStatus.paused_insufficient_credits,
|
|
21
41
|
]);
|
|
42
|
+
function isValidationDetails(value) {
|
|
43
|
+
return (Array.isArray(value) &&
|
|
44
|
+
value.length > 0 &&
|
|
45
|
+
value.every((el) => typeof el === 'object' &&
|
|
46
|
+
el !== null &&
|
|
47
|
+
typeof el.message === 'string'));
|
|
48
|
+
}
|
|
49
|
+
// An abort may surface as DOMException (browser + modern Node), a plain Error
|
|
50
|
+
// subclass with name='AbortError', or (rarely) a plain object with that name
|
|
51
|
+
// on less conformant runtimes. Match any non-null thing exposing the name.
|
|
52
|
+
function isAbortError(err) {
|
|
53
|
+
return (err !== null &&
|
|
54
|
+
typeof err === 'object' &&
|
|
55
|
+
err.name === 'AbortError');
|
|
56
|
+
}
|
|
57
|
+
// Retryable S3 PUT response statuses: 429 throttling, 503 slow-down, and any
|
|
58
|
+
// other 5xx (502/504 are common transients behind CloudFront/S3). 4xx other
|
|
59
|
+
// than 429 (403 signed-URL expiry, 400 SignatureDoesNotMatch, etc.) are
|
|
60
|
+
// configuration / authority issues — retrying just delays the real failure.
|
|
61
|
+
function isRetryableStatus(status) {
|
|
62
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
63
|
+
}
|
|
64
|
+
// fetch surfaces network failures (DNS, TLS, TCP reset, mid-body disconnect)
|
|
65
|
+
// as TypeError. Abort surfaces as a DOMException with name='AbortError', not
|
|
66
|
+
// a TypeError, so a plain instanceof check is sufficient — abort is filtered
|
|
67
|
+
// before reaching here by the dedicated isAbortError guard in the catch.
|
|
68
|
+
function isRetryableNetworkError(err) {
|
|
69
|
+
return err instanceof TypeError;
|
|
70
|
+
}
|
|
71
|
+
// Full-jitter exponential backoff: delay = random(0, base * 2^attemptIndex).
|
|
72
|
+
// AWS SDK guidance for shared-throttling sources like S3 — keeps competing
|
|
73
|
+
// clients from synchronising their retries.
|
|
74
|
+
function fullJitterDelay(baseMs, attemptIndex) {
|
|
75
|
+
if (baseMs <= 0)
|
|
76
|
+
return 0;
|
|
77
|
+
const ceiling = baseMs * Math.pow(2, attemptIndex);
|
|
78
|
+
return Math.floor(Math.random() * ceiling);
|
|
79
|
+
}
|
|
80
|
+
// Cancel a Response body so undici (Node 18+ fetch) releases the underlying
|
|
81
|
+
// connection promptly instead of waiting for GC. We swallow any error: the
|
|
82
|
+
// retry loop is about to re-PUT the chunk; failing the cleanup must not
|
|
83
|
+
// shadow the real failure that triggered the retry.
|
|
84
|
+
async function drainResponseBody(response) {
|
|
85
|
+
try {
|
|
86
|
+
await response.body?.cancel();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* ignore */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Reject NaN/Infinity and floor at 1 attempt. A misconfigured 0/negative
|
|
93
|
+
// still attempts the PUT once (so callers see the underlying error rather
|
|
94
|
+
// than a silent zero-PUT no-op).
|
|
95
|
+
function sanitiseAttempts(value, fallback) {
|
|
96
|
+
if (value === undefined || !Number.isFinite(value))
|
|
97
|
+
return fallback;
|
|
98
|
+
return Math.max(1, Math.floor(value));
|
|
99
|
+
}
|
|
100
|
+
// Reject NaN/Infinity and clamp at 0. `0` is permitted so callers can opt out
|
|
101
|
+
// of backoff entirely (e.g. for fast-path tests); `Infinity` would otherwise
|
|
102
|
+
// stall the retry loop indefinitely on the very first backoff.
|
|
103
|
+
function sanitiseBaseMs(value, fallback) {
|
|
104
|
+
if (value === undefined || !Number.isFinite(value))
|
|
105
|
+
return fallback;
|
|
106
|
+
return Math.max(0, Math.floor(value));
|
|
107
|
+
}
|
|
108
|
+
// Reject NaN/Infinity and snap to fallback for any value below 1. Diverges
|
|
109
|
+
// from sanitiseAttempts (which floors at 1) because zero workers here is
|
|
110
|
+
// not a "fail-fast once" semantic — `Math.min(0, queue.length) = 0` at the
|
|
111
|
+
// worker fan-out site below produces zero S3 PUTs, so /multipart/complete
|
|
112
|
+
// is then called with an incomplete `parts` array (silent corruption).
|
|
113
|
+
// Garbage input almost certainly meant "use the default", not "send no parts".
|
|
114
|
+
function sanitiseConcurrency(value, fallback) {
|
|
115
|
+
if (value === undefined || !Number.isFinite(value))
|
|
116
|
+
return fallback;
|
|
117
|
+
const floored = Math.floor(value);
|
|
118
|
+
return floored < 1 ? fallback : floored;
|
|
119
|
+
}
|
|
120
|
+
// Cancellable sleep. Resolves after `ms` ms, rejects with GislAbortError if
|
|
121
|
+
// the caller's signal aborts, or resolves early (without throwing) if the
|
|
122
|
+
// internal `wakeSignal` fires — that path lets a sibling worker's terminal
|
|
123
|
+
// failure short-circuit a peer's backoff sleep without producing a spurious
|
|
124
|
+
// abort error in the peer's own throw stack.
|
|
125
|
+
function sleepWithEitherSignal(ms, abortSignal, wakeSignal) {
|
|
126
|
+
if (abortSignal?.aborted) {
|
|
127
|
+
return Promise.reject(new GislAbortError('Multipart upload aborted'));
|
|
128
|
+
}
|
|
129
|
+
if (wakeSignal.aborted || ms <= 0)
|
|
130
|
+
return Promise.resolve();
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const cleanup = () => {
|
|
133
|
+
abortSignal?.removeEventListener('abort', onAbort);
|
|
134
|
+
wakeSignal.removeEventListener('abort', onWake);
|
|
135
|
+
};
|
|
136
|
+
const onAbort = () => {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
cleanup();
|
|
139
|
+
reject(new GislAbortError('Multipart upload aborted'));
|
|
140
|
+
};
|
|
141
|
+
const onWake = () => {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
cleanup();
|
|
144
|
+
resolve();
|
|
145
|
+
};
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
cleanup();
|
|
148
|
+
resolve();
|
|
149
|
+
}, ms);
|
|
150
|
+
abortSignal?.addEventListener('abort', onAbort, { once: true });
|
|
151
|
+
wakeSignal.addEventListener('abort', onWake, { once: true });
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// Wire an optional external AbortSignal onto an internal per-request controller
|
|
155
|
+
// so either source trips the composed fetch. The `onExternalAbort` callback
|
|
156
|
+
// fires the moment the external signal aborts — callers use it to capture the
|
|
157
|
+
// temporal order of user-vs-timeout causes, so the tiebreak in request() does
|
|
158
|
+
// not rely on `signal.aborted` read at catch time (which flips true regardless
|
|
159
|
+
// of which cause actually fired first).
|
|
160
|
+
//
|
|
161
|
+
// Returns a teardown that removes the listener — must be called in a finally
|
|
162
|
+
// so long-lived user AbortControllers do not accumulate listeners across many
|
|
163
|
+
// uploads. Node 18+ compatible (no AbortSignal.any).
|
|
164
|
+
function bindAbortSignal(external, internal, onExternalAbort) {
|
|
165
|
+
if (!external)
|
|
166
|
+
return () => { };
|
|
167
|
+
if (external.aborted) {
|
|
168
|
+
onExternalAbort?.();
|
|
169
|
+
internal.abort();
|
|
170
|
+
return () => { };
|
|
171
|
+
}
|
|
172
|
+
const onAbort = () => {
|
|
173
|
+
onExternalAbort?.();
|
|
174
|
+
internal.abort();
|
|
175
|
+
};
|
|
176
|
+
external.addEventListener('abort', onAbort, { once: true });
|
|
177
|
+
return () => external.removeEventListener('abort', onAbort);
|
|
178
|
+
}
|
|
22
179
|
export class GislClient {
|
|
23
180
|
baseUrl;
|
|
24
181
|
headers;
|
|
25
182
|
timeoutMs;
|
|
26
183
|
multipartThreshold;
|
|
27
184
|
multipartConcurrency;
|
|
185
|
+
multipartMaxAttempts;
|
|
186
|
+
multipartRetryBaseMs;
|
|
187
|
+
useSessionCookie;
|
|
28
188
|
constructor(config) {
|
|
29
189
|
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
30
190
|
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
191
|
+
this.useSessionCookie = config.useSessionCookie ?? false;
|
|
31
192
|
// Floor the threshold at the first-chunk size: the multipart initiate
|
|
32
193
|
// must always carry an 8MB chunk, so routing a sub-8MB file into the
|
|
33
194
|
// multipart path would violate the contract.
|
|
34
|
-
this.multipartThreshold = Math.max(config.multipartThreshold ??
|
|
35
|
-
this.multipartConcurrency = config.multipartConcurrency
|
|
195
|
+
this.multipartThreshold = Math.max(config.multipartThreshold ?? SINGLE_SHOT_MAX_BYTES, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
|
|
196
|
+
this.multipartConcurrency = sanitiseConcurrency(config.multipartConcurrency, MULTIPART_CONCURRENCY_DEFAULT);
|
|
197
|
+
// Sanitise: reject NaN/Infinity (the former would cause `attempt < NaN`
|
|
198
|
+
// to be perpetually false, skipping every PUT; the latter would retry
|
|
199
|
+
// unboundedly). Floor at 1 so a misconfigured 0/negative still attempts
|
|
200
|
+
// once.
|
|
201
|
+
this.multipartMaxAttempts = sanitiseAttempts(config.multipartMaxAttempts, DEFAULT_MULTIPART_MAX_ATTEMPTS);
|
|
202
|
+
this.multipartRetryBaseMs = sanitiseBaseMs(config.multipartRetryBaseMs, DEFAULT_MULTIPART_RETRY_BASE_MS);
|
|
36
203
|
this.headers = { ...config.headers };
|
|
37
204
|
if (config.apiKey) {
|
|
38
205
|
this.headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
@@ -42,8 +209,12 @@ export class GislClient {
|
|
|
42
209
|
// Internal HTTP
|
|
43
210
|
// -----------------------------------------------------------------------
|
|
44
211
|
async request(method, path, opts = {}) {
|
|
212
|
+
// Fast-fail on a pre-aborted user signal before building the request.
|
|
213
|
+
if (opts.signal?.aborted) {
|
|
214
|
+
throw new GislAbortError(`Request to ${method} ${path} aborted`);
|
|
215
|
+
}
|
|
45
216
|
const url = `${this.baseUrl}${path}`;
|
|
46
|
-
const headers = { ...this.headers };
|
|
217
|
+
const headers = { ...this.headers, ...opts.headers };
|
|
47
218
|
let body;
|
|
48
219
|
if (opts.json !== false && opts.body && !(opts.body instanceof FormData)) {
|
|
49
220
|
headers['Content-Type'] = 'application/json';
|
|
@@ -53,7 +224,22 @@ export class GislClient {
|
|
|
53
224
|
body = opts.body;
|
|
54
225
|
}
|
|
55
226
|
const controller = new AbortController();
|
|
56
|
-
|
|
227
|
+
// Record the first cause that tripped the composed controller. Checking
|
|
228
|
+
// `opts.signal.aborted` alone at catch time is not sound: if the timer
|
|
229
|
+
// fires first and the user signal aborts microseconds later (before
|
|
230
|
+
// `catch` runs), that flag is also true — but the true cause was the
|
|
231
|
+
// timeout. Capturing which side fired first gives a deterministic
|
|
232
|
+
// classification regardless of scheduling.
|
|
233
|
+
let firstCause = null;
|
|
234
|
+
const timer = setTimeout(() => {
|
|
235
|
+
if (firstCause === null)
|
|
236
|
+
firstCause = 'timeout';
|
|
237
|
+
controller.abort();
|
|
238
|
+
}, this.timeoutMs);
|
|
239
|
+
const unbind = bindAbortSignal(opts.signal, controller, () => {
|
|
240
|
+
if (firstCause === null)
|
|
241
|
+
firstCause = 'user';
|
|
242
|
+
});
|
|
57
243
|
let response;
|
|
58
244
|
try {
|
|
59
245
|
response = await fetch(url, {
|
|
@@ -61,48 +247,143 @@ export class GislClient {
|
|
|
61
247
|
headers,
|
|
62
248
|
body,
|
|
63
249
|
signal: controller.signal,
|
|
250
|
+
// `credentials: 'include'` on every request when the consumer opts
|
|
251
|
+
// into cookie-based auth (Symfony session via /api/auth/login).
|
|
252
|
+
// No-op in Node (fetch ignores the field there); mandatory for
|
|
253
|
+
// cross-origin browser SPAs to send the session cookie.
|
|
254
|
+
...(this.useSessionCookie ? { credentials: 'include' } : {}),
|
|
64
255
|
});
|
|
65
256
|
}
|
|
66
257
|
catch (err) {
|
|
67
|
-
if (err
|
|
258
|
+
if (isAbortError(err)) {
|
|
259
|
+
if (firstCause === 'user') {
|
|
260
|
+
throw new GislAbortError(`Request to ${method} ${path} aborted`);
|
|
261
|
+
}
|
|
68
262
|
throw new GislTimeoutError(`Request to ${method} ${path} timed out after ${this.timeoutMs}ms`);
|
|
69
263
|
}
|
|
70
264
|
throw err;
|
|
71
265
|
}
|
|
72
266
|
finally {
|
|
73
267
|
clearTimeout(timer);
|
|
268
|
+
unbind();
|
|
74
269
|
}
|
|
75
270
|
if (opts.rawResponse) {
|
|
76
271
|
return response;
|
|
77
272
|
}
|
|
273
|
+
// 204 No Content — contracted success status for endpoints that return
|
|
274
|
+
// no body (e.g. POST /api/contact). Short-circuit before handleResponse
|
|
275
|
+
// so an empty body never trips the JSON parser.
|
|
276
|
+
if (response.status === 204) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
78
279
|
return this.handleResponse(response, path, opts.deserialize);
|
|
79
280
|
}
|
|
80
281
|
async handleResponse(response, path, deserialize) {
|
|
81
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
82
|
-
|
|
282
|
+
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
|
283
|
+
const isJsonContent = contentType.includes('application/json') || contentType.includes('+json');
|
|
284
|
+
if (!isJsonContent) {
|
|
83
285
|
if (!response.ok) {
|
|
84
|
-
throw new GislApiError(response.status,
|
|
286
|
+
throw new GislApiError(response.status, 'Non-JSON response', path);
|
|
85
287
|
}
|
|
86
288
|
return undefined;
|
|
87
289
|
}
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
290
|
+
// Wire-side fields are snake_case (raw response.json() — never run through
|
|
291
|
+
// FromJSON helpers here). The structured-error subclasses receive a typed
|
|
292
|
+
// payload built via the per-envelope FromJSON helper, which handles the
|
|
293
|
+
// snake_case -> camelCase conversion for nested fields.
|
|
294
|
+
let json;
|
|
295
|
+
try {
|
|
296
|
+
json = await response.json();
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
throw new GislApiError(response.status, 'Invalid JSON response', path);
|
|
95
300
|
}
|
|
96
301
|
// Standard envelope: { success, data } or { success, error, details }
|
|
97
302
|
if (!response.ok || json.success === false) {
|
|
98
|
-
|
|
99
|
-
|
|
303
|
+
// Localisation triple per ticket I26 — surfaced on every typed error so
|
|
304
|
+
// consumers can drive client-side i18n catalogs without unwrapping the
|
|
305
|
+
// typed payload. Field names are wire snake_case here; the typed payload
|
|
306
|
+
// (built via FromJSON below) carries camelCase copies.
|
|
307
|
+
const i18n = {
|
|
308
|
+
messageKey: json.message_key,
|
|
309
|
+
locale: json.locale,
|
|
310
|
+
messageParams: json.message_params,
|
|
311
|
+
};
|
|
312
|
+
// Validation-details branch first — preserve existing shape so callers
|
|
313
|
+
// matching on `instanceof GislValidationError` keep working.
|
|
314
|
+
if (isValidationDetails(json.details)) {
|
|
315
|
+
throw new GislValidationError(response.status, json.error ?? 'Validation error', json.details, path, i18n);
|
|
316
|
+
}
|
|
317
|
+
// Dispatch by (status, error_type) onto the structured envelope shapes
|
|
318
|
+
// emitted by the v2 contracts. Each branch builds the typed payload via
|
|
319
|
+
// the generated FromJSON helper so consumers reading e.g.
|
|
320
|
+
// `error.payload.errorType` see camelCase fields rather than the raw
|
|
321
|
+
// wire snake_case.
|
|
322
|
+
//
|
|
323
|
+
// Defense-in-depth: if a malformed wire envelope causes the FromJSON
|
|
324
|
+
// helper to throw or coerce a required field to a sentinel value
|
|
325
|
+
// (e.g. `expired_at` missing -> `new Date(undefined)` => Invalid Date),
|
|
326
|
+
// fall through to the base `GislApiError` rather than handing the
|
|
327
|
+
// caller silently-corrupted typed metadata.
|
|
328
|
+
const errorType = json.error_type;
|
|
329
|
+
const status = response.status;
|
|
330
|
+
const errorMessage = json.error ?? 'Unknown error';
|
|
331
|
+
// Build the typed payload via FromJSON, then validate that all
|
|
332
|
+
// required typed fields are well-formed. FromJSON does not throw on
|
|
333
|
+
// missing required fields — for example `workflow_expired` without
|
|
334
|
+
// `expired_at` produces `new Date(undefined)` => Invalid Date with
|
|
335
|
+
// `getTime() === NaN`. Without an explicit validity check the error
|
|
336
|
+
// would surface a silently-corrupted typed payload instead of falling
|
|
337
|
+
// through to the generic base class.
|
|
338
|
+
const tryThrowStructured = (construct, ErrorClass, validate) => {
|
|
339
|
+
let payload;
|
|
340
|
+
try {
|
|
341
|
+
payload = construct(json);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
if (validate && !validate(payload)) {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
throw new ErrorClass(status, errorMessage, payload, path, i18n);
|
|
350
|
+
};
|
|
351
|
+
const isValidDate = (d) => d instanceof Date && !Number.isNaN(d.getTime());
|
|
352
|
+
if (status === 401 || status === 403) {
|
|
353
|
+
if (errorType && this.isAuthErrorType(errorType)) {
|
|
354
|
+
tryThrowStructured(AuthErrorResponseFromJSON, GislAuthError, (p) => typeof p.errorType === 'string');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const isInEnum = (value, members) => typeof value === 'string' && Object.values(members).includes(value);
|
|
358
|
+
const isFeatureViolation = (v) => typeof v === 'object' && v !== null
|
|
359
|
+
&& typeof v.feature === 'string';
|
|
360
|
+
if (status === 402 && errorType === 'balance_exhausted') {
|
|
361
|
+
tryThrowStructured(BalanceExhaustedResponseFromJSON, GislBalanceExhaustedError, (p) => isInEnum(p.requiredAction, BalanceExhaustedResponseRequiredActionEnum));
|
|
362
|
+
}
|
|
363
|
+
if (status === 403 && errorType === 'tier_restriction') {
|
|
364
|
+
tryThrowStructured(TierRestrictionResponseFromJSON, GislTierRestrictedError, (p) => isInEnum(p.restrictionKind, TierRestrictionKind)
|
|
365
|
+
&& isInEnum(p.currentTier, UserTier));
|
|
366
|
+
}
|
|
367
|
+
if (status === 403 && errorType === 'feature_tier_restricted') {
|
|
368
|
+
tryThrowStructured(FeatureTierRestrictedResponseFromJSON, GislFeatureTierRestrictedError, (p) => Array.isArray(p.violations) && p.violations.every(isFeatureViolation));
|
|
369
|
+
}
|
|
370
|
+
if (status === 422 && errorType === 'feature_not_available') {
|
|
371
|
+
tryThrowStructured(FeatureNotAvailableResponseFromJSON, GislFeatureNotAvailableError, (p) => Array.isArray(p.violations) && p.violations.every(isFeatureViolation));
|
|
372
|
+
}
|
|
373
|
+
if (status === 422 && errorType === 'workflow_expired') {
|
|
374
|
+
tryThrowStructured(WorkflowExpiredResponseFromJSON, GislWorkflowExpiredError, (p) => isValidDate(p.expiredAt));
|
|
100
375
|
}
|
|
101
|
-
throw new GislApiError(
|
|
376
|
+
throw new GislApiError(status, errorMessage, path, json.details, { ...i18n, payload: json });
|
|
102
377
|
}
|
|
103
378
|
const data = json.data ?? json;
|
|
104
379
|
return deserialize ? deserialize(data) : data;
|
|
105
380
|
}
|
|
381
|
+
// Membership check for the AuthErrorType discriminator. Reads the generated
|
|
382
|
+
// enum object directly so a future contract addition lands here without a
|
|
383
|
+
// hand-edit. The bundle cost is one tiny `as const` literal map (8 entries).
|
|
384
|
+
isAuthErrorType(value) {
|
|
385
|
+
return Object.values(AuthErrorType).includes(value);
|
|
386
|
+
}
|
|
106
387
|
// -----------------------------------------------------------------------
|
|
107
388
|
// Upload
|
|
108
389
|
// -----------------------------------------------------------------------
|
|
@@ -114,6 +395,11 @@ export class GislClient {
|
|
|
114
395
|
* @param options Upload options including progress callback.
|
|
115
396
|
*/
|
|
116
397
|
async uploadFile(file, options) {
|
|
398
|
+
// Pre-abort check: bail before statSync/readFileSync buffers the whole
|
|
399
|
+
// file into memory when the caller has already cancelled.
|
|
400
|
+
if (options?.signal?.aborted) {
|
|
401
|
+
throw new GislAbortError('Upload aborted before start');
|
|
402
|
+
}
|
|
117
403
|
let blob;
|
|
118
404
|
let fileName;
|
|
119
405
|
let fileSize;
|
|
@@ -132,17 +418,29 @@ export class GislClient {
|
|
|
132
418
|
if (fileSize > this.multipartThreshold) {
|
|
133
419
|
return this.multipartUpload(blob, fileName, fileSize, options);
|
|
134
420
|
}
|
|
135
|
-
return this.singleUpload(blob, fileName);
|
|
421
|
+
return this.singleUpload(blob, fileName, options);
|
|
136
422
|
}
|
|
137
|
-
async singleUpload(blob, fileName) {
|
|
423
|
+
async singleUpload(blob, fileName, options) {
|
|
138
424
|
const form = new FormData();
|
|
139
425
|
form.append('file', blob, fileName);
|
|
140
426
|
return this.request('POST', '/api/uploads', {
|
|
141
427
|
body: form,
|
|
142
428
|
json: false,
|
|
143
429
|
deserialize: UploadResponseFromJSON,
|
|
430
|
+
signal: options?.signal,
|
|
144
431
|
});
|
|
145
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* Direct-to-S3 multipart upload for files above the threshold.
|
|
435
|
+
*
|
|
436
|
+
* The /multipart/complete response (MultipartCompleteResponse) only carries
|
|
437
|
+
* { upload_id, status }. The server's upload_id is the same UUID callers
|
|
438
|
+
* pass as file_id to POST /api/workflows — so fileId is synthesised from
|
|
439
|
+
* upload_id and a full UploadResponse is returned to keep the public
|
|
440
|
+
* uploadFile() API uniform across single and multipart paths. The mimeType
|
|
441
|
+
* comes from the initiate response's first-chunk detection; for authoritative
|
|
442
|
+
* post-upload metadata callers should use getMetadata(fileId).
|
|
443
|
+
*/
|
|
146
444
|
async multipartUpload(blob, fileName, totalSize, options) {
|
|
147
445
|
// Step 1: Initiate with first chunk
|
|
148
446
|
const firstChunkSize = Math.min(totalSize, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
|
|
@@ -151,10 +449,23 @@ export class GislClient {
|
|
|
151
449
|
initiateForm.append('file', firstChunk, fileName);
|
|
152
450
|
initiateForm.append('filename', fileName);
|
|
153
451
|
initiateForm.append('total_size', totalSize.toString());
|
|
452
|
+
if (options?.metadataHint !== undefined) {
|
|
453
|
+
// Wire format: a single FormData field carrying the JSON-stringified
|
|
454
|
+
// hint object. Single-shot uploads do not accept this field — see
|
|
455
|
+
// singleUpload() which silently ignores `options.metadataHint`.
|
|
456
|
+
// Route through the generated ToJSON helper so the wire form is the
|
|
457
|
+
// contract-pinned snake_case shape (`duration_seconds`, `width`,
|
|
458
|
+
// `height`). Stringify-ing the camelCase TS object directly would
|
|
459
|
+
// emit `durationSeconds` and the server would silently drop it,
|
|
460
|
+
// defeating the hint's primary use (long-form pre-classification when
|
|
461
|
+
// the first-chunk probe lacks container metadata).
|
|
462
|
+
initiateForm.append('metadata_hint', JSON.stringify(MultipartInitiateRequestMetadataHintToJSON(options.metadataHint)));
|
|
463
|
+
}
|
|
154
464
|
const initResponse = await this.request('POST', '/api/uploads/multipart/initiate', {
|
|
155
465
|
body: initiateForm,
|
|
156
466
|
json: false,
|
|
157
467
|
deserialize: MultipartInitiateResponseFromJSON,
|
|
468
|
+
signal: options?.signal,
|
|
158
469
|
});
|
|
159
470
|
let uploadedBytes = firstChunkSize;
|
|
160
471
|
options?.onProgress?.(uploadedBytes, totalSize);
|
|
@@ -162,45 +473,179 @@ export class GislClient {
|
|
|
162
473
|
const etags = [];
|
|
163
474
|
const presignedUrls = initResponse.presignedUrls;
|
|
164
475
|
const chunkSize = initResponse.recommendedChunkSize;
|
|
476
|
+
// Internal abort signal that workers use to short-circuit each others'
|
|
477
|
+
// backoff sleeps. When any worker hits a terminal failure it aborts this
|
|
478
|
+
// controller, which races the caller's signal inside sleepWithSignal so
|
|
479
|
+
// sleeping siblings stop waiting for their timer to expire.
|
|
480
|
+
const failureController = new AbortController();
|
|
481
|
+
// Single PUT attempt. Returns a structured outcome instead of throwing
|
|
482
|
+
// for retryable/non-retryable distinctions, so the caller can decide
|
|
483
|
+
// whether to loop without conflating user-callback errors with
|
|
484
|
+
// network-layer retries (codex review).
|
|
485
|
+
const attemptPut = async (part, chunk, contentLength) => {
|
|
486
|
+
let s3Response;
|
|
487
|
+
try {
|
|
488
|
+
s3Response = await fetch(part.url, {
|
|
489
|
+
method: 'PUT',
|
|
490
|
+
body: chunk,
|
|
491
|
+
headers: { 'Content-Length': contentLength.toString() },
|
|
492
|
+
signal: options?.signal,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
if (isAbortError(err) && options?.signal?.aborted) {
|
|
497
|
+
return {
|
|
498
|
+
kind: 'fatal',
|
|
499
|
+
err: new GislAbortError(`S3 part ${part.partNumber} upload aborted`),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
if (isRetryableNetworkError(err)) {
|
|
503
|
+
return { kind: 'retryable', lastErr: err };
|
|
504
|
+
}
|
|
505
|
+
return { kind: 'fatal', err };
|
|
506
|
+
}
|
|
507
|
+
if (s3Response.ok) {
|
|
508
|
+
const etag = s3Response.headers.get('etag');
|
|
509
|
+
if (!etag) {
|
|
510
|
+
// Drain the body even though we're failing fast — keeps the
|
|
511
|
+
// connection released eagerly.
|
|
512
|
+
await drainResponseBody(s3Response);
|
|
513
|
+
return {
|
|
514
|
+
kind: 'fatal',
|
|
515
|
+
err: new GislError(`S3 response missing ETag for part ${part.partNumber}`),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
return { kind: 'ok', etag };
|
|
519
|
+
}
|
|
520
|
+
// Non-OK: drain the body in BOTH branches before deciding. Undici
|
|
521
|
+
// holds the connection open until the body is consumed regardless of
|
|
522
|
+
// whether we retry.
|
|
523
|
+
await drainResponseBody(s3Response);
|
|
524
|
+
if (!isRetryableStatus(s3Response.status)) {
|
|
525
|
+
return {
|
|
526
|
+
kind: 'fatal',
|
|
527
|
+
err: new GislError(`S3 chunk upload failed for part ${part.partNumber}: ${s3Response.status}`),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
kind: 'retryable',
|
|
532
|
+
lastErr: new GislError(`S3 chunk upload failed for part ${part.partNumber}: ${s3Response.status}`),
|
|
533
|
+
};
|
|
534
|
+
};
|
|
165
535
|
const uploadChunk = async (index) => {
|
|
166
536
|
const part = presignedUrls[index];
|
|
167
537
|
const start = firstChunkSize + index * chunkSize;
|
|
168
538
|
const end = Math.min(start + chunkSize, totalSize);
|
|
539
|
+
// Blob.slice() returns a new Blob view; the underlying bytes are
|
|
540
|
+
// immutable so the same `chunk` may be re-sent across retry attempts.
|
|
541
|
+
// S3 multipart parts are idempotent by partNumber — a re-PUT overwrites,
|
|
542
|
+
// there is no duplicate-data risk.
|
|
169
543
|
const chunk = blob.slice(start, end);
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
544
|
+
const contentLength = end - start;
|
|
545
|
+
let lastErr = null;
|
|
546
|
+
for (let attempt = 0; attempt < this.multipartMaxAttempts; attempt++) {
|
|
547
|
+
if (options?.signal?.aborted) {
|
|
548
|
+
throw new GislAbortError(`S3 part ${part.partNumber} upload aborted`);
|
|
549
|
+
}
|
|
550
|
+
if (failureController.signal.aborted) {
|
|
551
|
+
// Sibling worker hit a terminal failure: bail before dispatching a
|
|
552
|
+
// wasted PUT. The thrown error is swallowed by the outer worker
|
|
553
|
+
// loop — Promise.all has already settled with the first failure.
|
|
554
|
+
throw new GislError(`S3 part ${part.partNumber} upload abandoned after sibling worker failure`);
|
|
555
|
+
}
|
|
556
|
+
const outcome = await attemptPut(part, chunk, contentLength);
|
|
557
|
+
if (outcome.kind === 'ok') {
|
|
558
|
+
// Apply progress side effects OUTSIDE the retry-scoped path so a
|
|
559
|
+
// user-callback throw does not trigger a duplicate PUT (codex
|
|
560
|
+
// review: retrying after a successful PUT would double-record the
|
|
561
|
+
// ETag and re-upload an already accepted part).
|
|
562
|
+
etags.push({ partNumber: part.partNumber, etag: outcome.etag });
|
|
563
|
+
uploadedBytes += contentLength;
|
|
564
|
+
options?.onProgress?.(uploadedBytes, totalSize);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (outcome.kind === 'fatal') {
|
|
568
|
+
throw outcome.err;
|
|
569
|
+
}
|
|
570
|
+
lastErr = outcome.lastErr;
|
|
571
|
+
if (attempt + 1 >= this.multipartMaxAttempts)
|
|
572
|
+
break;
|
|
573
|
+
const delay = fullJitterDelay(this.multipartRetryBaseMs, attempt);
|
|
574
|
+
// Race the caller's signal AND the sibling-failure signal so a
|
|
575
|
+
// worker that fails terminally wakes its sleeping peers instead of
|
|
576
|
+
// forcing them to wait out their backoff timer.
|
|
577
|
+
await sleepWithEitherSignal(delay, options?.signal, failureController.signal);
|
|
181
578
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
options?.onProgress?.(uploadedBytes, totalSize);
|
|
579
|
+
throw new GislError(`S3 chunk upload failed for part ${part.partNumber} after ${this.multipartMaxAttempts} attempts: ` +
|
|
580
|
+
(lastErr instanceof Error ? lastErr.message : String(lastErr)));
|
|
185
581
|
};
|
|
186
|
-
// Upload with concurrency limit
|
|
582
|
+
// Upload with concurrency limit. Workers check the signal before pulling
|
|
583
|
+
// the next queue item so a mid-upload abort drains fast without
|
|
584
|
+
// dispatching new chunks. Chunks already in-flight are cancelled via the
|
|
585
|
+
// composed signal passed to fetch above; sleeping siblings are woken via
|
|
586
|
+
// the failureController set below.
|
|
187
587
|
const queue = [...presignedUrls.keys()];
|
|
188
588
|
const workers = Array.from({ length: Math.min(this.multipartConcurrency, queue.length) }, async () => {
|
|
189
|
-
while (queue.length > 0) {
|
|
589
|
+
while (queue.length > 0 && !failureController.signal.aborted) {
|
|
590
|
+
if (options?.signal?.aborted) {
|
|
591
|
+
throw new GislAbortError('Multipart upload aborted');
|
|
592
|
+
}
|
|
190
593
|
const index = queue.shift();
|
|
191
|
-
|
|
594
|
+
try {
|
|
595
|
+
await uploadChunk(index);
|
|
596
|
+
}
|
|
597
|
+
catch (err) {
|
|
598
|
+
// Wake any sibling currently in a backoff sleep, and prevent
|
|
599
|
+
// siblings from picking up further queue items.
|
|
600
|
+
failureController.abort();
|
|
601
|
+
throw err;
|
|
602
|
+
}
|
|
192
603
|
}
|
|
193
604
|
});
|
|
194
605
|
await Promise.all(workers);
|
|
195
|
-
// Step 3: Complete multipart upload
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
606
|
+
// Step 3: Complete multipart upload.
|
|
607
|
+
// Build a typed MultipartCompleteRequest and serialise via the generated
|
|
608
|
+
// ToJSON helper — tsc now catches any field-name drift between the SDK
|
|
609
|
+
// and the OpenAPI spec (see contract-drift-fields.test.ts describe 'c').
|
|
610
|
+
etags.sort((a, b) => a.partNumber - b.partNumber);
|
|
611
|
+
const completeRequest = {
|
|
612
|
+
uploadId: initResponse.uploadId,
|
|
613
|
+
parts: etags,
|
|
614
|
+
};
|
|
615
|
+
// MultipartCompleteRequestToJSON's declared return type is the camelCase
|
|
616
|
+
// `MultipartCompleteRequest` interface, but at runtime it returns the
|
|
617
|
+
// snake_case wire object — an openapi-generator v7 quirk. The drift gate
|
|
618
|
+
// for field names lives at `completeRequest: MultipartCompleteRequest`
|
|
619
|
+
// above; the local wire type + runtime sanity check below guard against
|
|
620
|
+
// the remaining hypothetical: a future generator version emitting a
|
|
621
|
+
// different shape without the declared type catching it.
|
|
622
|
+
const wireCompleteBody = MultipartCompleteRequestToJSON(completeRequest);
|
|
623
|
+
if (typeof wireCompleteBody?.upload_id !== 'string' ||
|
|
624
|
+
!Array.isArray(wireCompleteBody?.parts)) {
|
|
625
|
+
throw new GislError('MultipartCompleteRequestToJSON returned an unexpected shape — generator output may have changed.');
|
|
626
|
+
}
|
|
627
|
+
const completeResp = await this.request('POST', '/api/uploads/multipart/complete', {
|
|
628
|
+
body: wireCompleteBody,
|
|
629
|
+
deserialize: MultipartCompleteResponseFromJSON,
|
|
630
|
+
signal: options?.signal,
|
|
203
631
|
});
|
|
632
|
+
// Defensive: the status enum currently has only 'completed', but guard
|
|
633
|
+
// against future expansion so an unexpected terminal state doesn't pass
|
|
634
|
+
// as a successful upload.
|
|
635
|
+
if (completeResp.status !== 'completed') {
|
|
636
|
+
throw new GislError(`Multipart upload completed with unexpected status: ${completeResp.status}`);
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
fileId: completeResp.uploadId,
|
|
640
|
+
originalName: fileName,
|
|
641
|
+
mimeType: initResponse.mimeType,
|
|
642
|
+
sizeBytes: blob.size,
|
|
643
|
+
// Preserved from the initiate response: v2 contract makes
|
|
644
|
+
// `constraintsApplied` a REQUIRED field on UploadResponse, and the
|
|
645
|
+
// multipart/complete endpoint does not re-emit it. The first-chunk probe
|
|
646
|
+
// result on the initiate envelope is the authoritative source.
|
|
647
|
+
constraintsApplied: initResponse.constraintsApplied,
|
|
648
|
+
};
|
|
204
649
|
}
|
|
205
650
|
// -----------------------------------------------------------------------
|
|
206
651
|
// Workflows
|
|
@@ -241,6 +686,49 @@ export class GislClient {
|
|
|
241
686
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
242
687
|
}
|
|
243
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Cancel a workflow. Idempotent — cancelling an already-cancelled
|
|
691
|
+
* workflow returns 200 with the same shape (and the original
|
|
692
|
+
* `cancelledAt`). Cancelling a `completed` / `failed` /
|
|
693
|
+
* `partially_failed` / `expired` workflow returns 409.
|
|
694
|
+
*
|
|
695
|
+
* The response's `billingEffect` field tells the caller what
|
|
696
|
+
* happened to outstanding reservations:
|
|
697
|
+
* - `unspent_reservation_released` — workflow was active or paused
|
|
698
|
+
* and the unspent portion of the reservation has been refunded.
|
|
699
|
+
* The refund appears as a separate `CreditTransaction` with
|
|
700
|
+
* `type: refund`.
|
|
701
|
+
* - `none` — no refund (all reserved credits were already consumed
|
|
702
|
+
* by completed jobs, or this is an idempotent re-cancel).
|
|
703
|
+
*
|
|
704
|
+
* In-flight operations may continue running briefly after the
|
|
705
|
+
* cancel response while their Lambda processes terminate; the
|
|
706
|
+
* response is the binding "no further reservations will be made"
|
|
707
|
+
* signal.
|
|
708
|
+
*/
|
|
709
|
+
async cancelWorkflow(workflowId) {
|
|
710
|
+
return this.request('POST', `/api/workflows/${encodeURIComponent(workflowId)}/cancel`, {
|
|
711
|
+
deserialize: WorkflowCancelResponseFromJSON,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Resume a workflow that is in `paused_insufficient_credits`.
|
|
716
|
+
*
|
|
717
|
+
* Resume succeeds only when `availableCredits` covers the next
|
|
718
|
+
* reservation. If the balance is still insufficient, throws
|
|
719
|
+
* `GislBalanceExhaustedError` (402, same envelope as the workflow-
|
|
720
|
+
* create 402 path) and the workflow stays paused. If the workflow
|
|
721
|
+
* is past its `expiresAt` (default 7-day TTL from `pausedAt`),
|
|
722
|
+
* throws `GislWorkflowExpiredError` (422) and the workflow has
|
|
723
|
+
* transitioned to `expired` — callers cannot un-expire a workflow.
|
|
724
|
+
* Resuming a workflow that is not in `paused_insufficient_credits`
|
|
725
|
+
* is a 409 (no-op).
|
|
726
|
+
*/
|
|
727
|
+
async resumeWorkflow(workflowId) {
|
|
728
|
+
return this.request('POST', `/api/workflows/${encodeURIComponent(workflowId)}/resume`, {
|
|
729
|
+
deserialize: WorkflowResumeResponseFromJSON,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
244
732
|
/**
|
|
245
733
|
* Get download URLs for a completed workflow.
|
|
246
734
|
*/
|
|
@@ -253,9 +741,10 @@ export class GislClient {
|
|
|
253
741
|
* Stream SSE events for a workflow. Returns an async iterable.
|
|
254
742
|
*/
|
|
255
743
|
async streamEvents(workflowId) {
|
|
256
|
-
const
|
|
744
|
+
const eventsPath = `/api/workflows/${encodeURIComponent(workflowId)}/events`;
|
|
745
|
+
const response = await this.request('GET', eventsPath, { rawResponse: true });
|
|
257
746
|
if (!response.ok) {
|
|
258
|
-
await this.handleResponse(response,
|
|
747
|
+
await this.handleResponse(response, eventsPath);
|
|
259
748
|
}
|
|
260
749
|
return parseSseStream(response);
|
|
261
750
|
}
|
|
@@ -275,12 +764,58 @@ export class GislClient {
|
|
|
275
764
|
// -----------------------------------------------------------------------
|
|
276
765
|
/**
|
|
277
766
|
* Get the operations schema (available types, options, constraints).
|
|
278
|
-
*
|
|
767
|
+
*
|
|
768
|
+
* Returns raw JSON (no envelope). The response is **per-tier private**
|
|
769
|
+
* (cache key includes the caller's `user_tier`); CDN-style public
|
|
770
|
+
* caching is not used. Pass `ifNoneMatch` / `ifModifiedSince` from a
|
|
771
|
+
* previous response to revalidate — a 304 surfaces as
|
|
772
|
+
* `{ notModified: true, etag, lastModified }` so callers can keep
|
|
773
|
+
* using their cached copy.
|
|
279
774
|
*/
|
|
280
|
-
async getSchema() {
|
|
281
|
-
|
|
282
|
-
|
|
775
|
+
async getSchema(options = {}) {
|
|
776
|
+
const params = new URLSearchParams();
|
|
777
|
+
if (options.mimeType !== undefined)
|
|
778
|
+
params.set('mime_type', options.mimeType);
|
|
779
|
+
if (options.operation !== undefined)
|
|
780
|
+
params.set('operation', options.operation);
|
|
781
|
+
const query = params.toString();
|
|
782
|
+
// The contract-drift test (tests/unit/contract-drift.test.ts) scans this
|
|
783
|
+
// file for path literals via a regex that picks up both single-quoted
|
|
784
|
+
// strings AND backtick templates. Embedding the querystring in a single
|
|
785
|
+
// template would normalise to a path-with-querystring that no contract
|
|
786
|
+
// path matches. Compose with concatenation so only the bare path appears
|
|
787
|
+
// as a literal.
|
|
788
|
+
const path = '/api/operations/schema' + (query ? '?' + query : '');
|
|
789
|
+
const headers = {};
|
|
790
|
+
if (options.ifNoneMatch !== undefined)
|
|
791
|
+
headers['If-None-Match'] = options.ifNoneMatch;
|
|
792
|
+
if (options.ifModifiedSince !== undefined)
|
|
793
|
+
headers['If-Modified-Since'] = options.ifModifiedSince;
|
|
794
|
+
const response = await this.request('GET', path, {
|
|
795
|
+
rawResponse: true,
|
|
796
|
+
signal: options.signal,
|
|
797
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
283
798
|
});
|
|
799
|
+
const etag = response.headers.get('etag') ?? undefined;
|
|
800
|
+
const lastModified = response.headers.get('last-modified') ?? undefined;
|
|
801
|
+
if (response.status === 304) {
|
|
802
|
+
return { notModified: true, etag, lastModified };
|
|
803
|
+
}
|
|
804
|
+
if (!response.ok) {
|
|
805
|
+
let errorMessage = 'Unknown error';
|
|
806
|
+
try {
|
|
807
|
+
const errJson = (await response.json());
|
|
808
|
+
if (errJson.error)
|
|
809
|
+
errorMessage = errJson.error;
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
// Non-JSON body — keep generic message.
|
|
813
|
+
}
|
|
814
|
+
throw new GislApiError(response.status, errorMessage, path);
|
|
815
|
+
}
|
|
816
|
+
const raw = await response.json();
|
|
817
|
+
const data = OperationsSchemaResponseFromJSON(raw);
|
|
818
|
+
return { notModified: false, data, etag, lastModified };
|
|
284
819
|
}
|
|
285
820
|
/**
|
|
286
821
|
* Retry a failed operation.
|
|
@@ -290,4 +825,213 @@ export class GislClient {
|
|
|
290
825
|
deserialize: RetryResponseFromJSON,
|
|
291
826
|
});
|
|
292
827
|
}
|
|
828
|
+
// -----------------------------------------------------------------------
|
|
829
|
+
// Contact
|
|
830
|
+
// -----------------------------------------------------------------------
|
|
831
|
+
/**
|
|
832
|
+
* Submit a contact-form message. The endpoint returns 204 No Content on
|
|
833
|
+
* success, so this method resolves to `void`.
|
|
834
|
+
*
|
|
835
|
+
* Validation errors (e.g. missing `email`, non-empty honeypot `website`)
|
|
836
|
+
* surface as `GislValidationError` from the standard error envelope.
|
|
837
|
+
*/
|
|
838
|
+
async submitContact(payload) {
|
|
839
|
+
await this.request('POST', '/api/contact', {
|
|
840
|
+
body: payload,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
// -----------------------------------------------------------------------
|
|
844
|
+
// Credits / billing
|
|
845
|
+
// -----------------------------------------------------------------------
|
|
846
|
+
/**
|
|
847
|
+
* Get a snapshot of the caller's current credit position. The canonical
|
|
848
|
+
* billing-state surface — `BalanceExhaustedResponse` (402) on workflow
|
|
849
|
+
* creation includes pre-error counters for context, but UIs should drive
|
|
850
|
+
* spend-now affordances and tier-upgrade prompts off this endpoint, not
|
|
851
|
+
* off the error envelope.
|
|
852
|
+
*/
|
|
853
|
+
async getCreditsBalance() {
|
|
854
|
+
return this.request('GET', '/api/v2/credits/balance', {
|
|
855
|
+
deserialize: CreditsBalanceResponseFromJSON,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
// -----------------------------------------------------------------------
|
|
859
|
+
// Auth
|
|
860
|
+
// -----------------------------------------------------------------------
|
|
861
|
+
/**
|
|
862
|
+
* Authenticate with email/password. On success the server issues a
|
|
863
|
+
* session cookie via `Set-Cookie`; subsequent requests authenticate
|
|
864
|
+
* via that cookie when the client is configured with
|
|
865
|
+
* `useSessionCookie: true`.
|
|
866
|
+
*
|
|
867
|
+
* Failure modes per ticket FX6mbTJD:
|
|
868
|
+
* - **401** `invalid_credentials` (collapsed with unverified
|
|
869
|
+
* accounts for anti-enumeration) → `GislAuthError`.
|
|
870
|
+
* - **403** account-state failures (`account_locked`,
|
|
871
|
+
* `account_disabled`, `account_deleted`,
|
|
872
|
+
* `account_deletion_expired`) → `GislAuthError`.
|
|
873
|
+
* - **429** infrastructure rate-limit → `GislApiError` with
|
|
874
|
+
* the `Retry-After` header echoed on the response.
|
|
875
|
+
*
|
|
876
|
+
* Node session persistence (cookie-jar across processes) is out of
|
|
877
|
+
* scope — this method only touches the request side.
|
|
878
|
+
*/
|
|
879
|
+
async login(credentials) {
|
|
880
|
+
return this.request('POST', '/api/auth/login', {
|
|
881
|
+
body: credentials,
|
|
882
|
+
deserialize: LoginUser200ResponseDataFromJSON,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Invalidate the current session.
|
|
887
|
+
*
|
|
888
|
+
* Idempotent: calling logout without an active session returns 401,
|
|
889
|
+
* but the SDK collapses both 200 and 401 into a single "logged out"
|
|
890
|
+
* outcome — `logout()` resolves to `void` in either case so caller
|
|
891
|
+
* cleanup code does not need to special-case the not-currently-
|
|
892
|
+
* authenticated path. Other errors (e.g. 500, network failures)
|
|
893
|
+
* still throw.
|
|
894
|
+
*/
|
|
895
|
+
async logout() {
|
|
896
|
+
try {
|
|
897
|
+
await this.request('POST', '/api/auth/logout', {});
|
|
898
|
+
}
|
|
899
|
+
catch (err) {
|
|
900
|
+
// Treat 401 as success (already logged out — idempotent per
|
|
901
|
+
// contract). Logout 401 is a bare ErrorEnvelope with no
|
|
902
|
+
// `error_type`, so it surfaces as the base GislApiError rather
|
|
903
|
+
// than the typed GislAuthError — match on the status code to
|
|
904
|
+
// capture both shapes.
|
|
905
|
+
if (err instanceof GislApiError && err.statusCode === 401) {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
throw err;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
// -----------------------------------------------------------------------
|
|
912
|
+
// External imports
|
|
913
|
+
// -----------------------------------------------------------------------
|
|
914
|
+
/**
|
|
915
|
+
* Register a one-shot bearer URL (S3 presigned, GCS signed, Azure
|
|
916
|
+
* SAS, Dropbox shared link, public HTTPS) and receive an opaque
|
|
917
|
+
* `externalSourceId` handle. Subsequent workflows reference the
|
|
918
|
+
* handle via `WorkflowSource` of `type: external_import` —
|
|
919
|
+
* compose with the [`externalImportSource()`](./types.ts) factory.
|
|
920
|
+
*
|
|
921
|
+
* Per ADR-0005 §"SSRF posture": the server validates 8 rules at
|
|
922
|
+
* registration time AND again at fetch time. HTTPS-only;
|
|
923
|
+
* private/loopback/cloud-metadata IPs are rejected (403). The
|
|
924
|
+
* original URL + password are encrypted at rest and never
|
|
925
|
+
* returned in any response.
|
|
926
|
+
*
|
|
927
|
+
* Currently `availability: planned` — the runtime endpoint returns
|
|
928
|
+
* 422 `feature_not_available` (or 404, per the cross-repo rollout)
|
|
929
|
+
* until the external-import infrastructure ships. The method
|
|
930
|
+
* exists today so consumers can write the integration ahead of
|
|
931
|
+
* time.
|
|
932
|
+
*/
|
|
933
|
+
async createExternalImport(payload) {
|
|
934
|
+
return this.request('POST', '/api/external-imports', {
|
|
935
|
+
body: ExternalImportRequestToJSON(payload),
|
|
936
|
+
deserialize: ExternalImportCreatedResponseFromJSON,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
// -----------------------------------------------------------------------
|
|
940
|
+
// Audio watermark
|
|
941
|
+
// -----------------------------------------------------------------------
|
|
942
|
+
/**
|
|
943
|
+
* Decode a previously-embedded steganographic audio watermark
|
|
944
|
+
* (per ticket I20). Pairs with the `audio_watermark` operation —
|
|
945
|
+
* the operation embeds; this endpoint decodes.
|
|
946
|
+
*
|
|
947
|
+
* **Enterprise tier only.** Free / pro callers receive
|
|
948
|
+
* `GislFeatureTierRestrictedError` (403).
|
|
949
|
+
*
|
|
950
|
+
* **Own watermarks only.** The decoder will refuse to extract from
|
|
951
|
+
* media the caller did not mark themselves — mismatches return 404
|
|
952
|
+
* (rather than leaking that *some* watermark was detected).
|
|
953
|
+
*
|
|
954
|
+
* Currently `availability: planned` — calls return
|
|
955
|
+
* `GislFeatureNotAvailableError` (422) until the cross-repo Lambda
|
|
956
|
+
* support ships. Decode requests are rate-limited independently
|
|
957
|
+
* from workflow-create.
|
|
958
|
+
*/
|
|
959
|
+
async decodeAudioWatermark(payload) {
|
|
960
|
+
// The generated request type is camelCase; convert to snake_case wire
|
|
961
|
+
// shape before sending. Mirrors the multipart complete pattern.
|
|
962
|
+
return this.request('POST', '/api/audio-watermark/decode', {
|
|
963
|
+
body: AudioWatermarkDecodeRequestToJSON(payload),
|
|
964
|
+
deserialize: AudioWatermarkDecodeResponseFromJSON,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
// -----------------------------------------------------------------------
|
|
968
|
+
// Upload probe / preflight
|
|
969
|
+
// -----------------------------------------------------------------------
|
|
970
|
+
/**
|
|
971
|
+
* Probe an uploaded file for workflow-readiness — detects corruption,
|
|
972
|
+
* unsupported codecs, and pre-assigns the processing class the server
|
|
973
|
+
* would route the file to. Designed for the long-form merge edge case
|
|
974
|
+
* where a single bad input would fail the whole workflow.
|
|
975
|
+
*
|
|
976
|
+
* Currently `availability: planned` — calls return
|
|
977
|
+
* `GislFeatureNotAvailableError` (422) until the cross-repo Lambda
|
|
978
|
+
* support ships. Idempotent: probing the same `fileId` twice returns
|
|
979
|
+
* the cached result.
|
|
980
|
+
*/
|
|
981
|
+
async probeUpload(fileId) {
|
|
982
|
+
return this.request('POST', `/api/uploads/${encodeURIComponent(fileId)}/probe`, {
|
|
983
|
+
deserialize: UploadProbeResponseFromJSON,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Probe N uploaded files in parallel and partition the results by
|
|
988
|
+
* outcome. Returns `{ ok, rejected, errors }` so the caller can
|
|
989
|
+
* cleanly drop bad clips before submitting a long-form merge
|
|
990
|
+
* workflow. Probe-call failures (including the
|
|
991
|
+
* `feature_not_available` 422 returned while the endpoint is
|
|
992
|
+
* `availability: planned`) land in `errors` rather than throwing,
|
|
993
|
+
* so a partially-successful batch still yields useful aggregation.
|
|
994
|
+
*/
|
|
995
|
+
async preflightClips(fileIds) {
|
|
996
|
+
const settled = await Promise.allSettled(fileIds.map((fileId) => this.probeUpload(fileId)));
|
|
997
|
+
const ok = [];
|
|
998
|
+
const rejected = [];
|
|
999
|
+
const errors = [];
|
|
1000
|
+
for (let i = 0; i < settled.length; i++) {
|
|
1001
|
+
const result = settled[i];
|
|
1002
|
+
const fileId = fileIds[i];
|
|
1003
|
+
if (result.status === 'fulfilled') {
|
|
1004
|
+
if (result.value.probeStatus === 'ok') {
|
|
1005
|
+
ok.push(result.value);
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
rejected.push(result.value);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
errors.push({ fileId, error: result.reason });
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return { ok, rejected, errors };
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Get a paginated page of credit transaction history for the caller.
|
|
1019
|
+
* Server defaults: `limit=20`, `offset=0`. Most-recent-first.
|
|
1020
|
+
*/
|
|
1021
|
+
async getCreditsUsage(options = {}) {
|
|
1022
|
+
const params = new URLSearchParams();
|
|
1023
|
+
if (options.limit !== undefined)
|
|
1024
|
+
params.set('limit', String(options.limit));
|
|
1025
|
+
if (options.offset !== undefined)
|
|
1026
|
+
params.set('offset', String(options.offset));
|
|
1027
|
+
const query = params.toString();
|
|
1028
|
+
// String concatenation (not template) so the contract-drift path scanner
|
|
1029
|
+
// picks up the literal path. See getSchema for the same pattern.
|
|
1030
|
+
const path = query.length > 0
|
|
1031
|
+
? '/api/v2/credits/usage' + '?' + query
|
|
1032
|
+
: '/api/v2/credits/usage';
|
|
1033
|
+
return this.request('GET', path, {
|
|
1034
|
+
deserialize: CreditsUsageResponseFromJSON,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
293
1037
|
}
|