@giveitsmaller/sdk 0.2.3 → 0.6.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 +64 -0
- package/dist/client.d.ts +255 -5
- package/dist/client.js +1681 -66
- package/dist/errors.d.ts +168 -9
- package/dist/errors.js +168 -3
- package/dist/index.d.ts +13 -6
- package/dist/index.js +29 -3
- package/dist/sse.d.ts +20 -1
- package/dist/sse.js +62 -3
- package/dist/types.d.ts +350 -37
- package/dist/types.js +28 -7
- package/package.json +5 -3
package/dist/client.js
CHANGED
|
@@ -1,45 +1,295 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { open, stat } from 'node:fs/promises';
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
|
-
import { UploadResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartCompleteResponseFromJSON, 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, UploadSizeExceedsTierResponseFromJSON, UploadDurationExceedsTierResponseFromJSON, UploadConstraintsAppliedProcessingClassPreAssignmentEnum, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
|
|
4
|
+
import { GislAbortError, GislApiError, GislAuthError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislUploadCapExceededError, 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_16777216;
|
|
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
|
|
26
|
+
// The ~2 GB wall on a single Node file read is NOT a Buffer-size limit
|
|
27
|
+
// (modern 64-bit `buffer.constants.MAX_LENGTH` is ~8 PiB). It is libuv's
|
|
28
|
+
// hard-coded INT32_MAX (2 147 483 647) ceiling on one `uv_fs_read` — some
|
|
29
|
+
// platforms reject I/O larger than INT32_MAX bytes per call, so libuv caps
|
|
30
|
+
// every read at it (github.com/nodejs/node/issues/55864). The streaming
|
|
31
|
+
// upload path never approaches this (server chunk size is bounded to
|
|
32
|
+
// <=100 MiB and the first chunk is fixed 8 MiB), but `fileByteSource`
|
|
33
|
+
// asserts it per read so any future caller that requests an oversized range
|
|
34
|
+
// fails loudly here instead of getting a silently short read from libuv.
|
|
35
|
+
const LIBUV_MAX_SINGLE_READ_BYTES = 0x7fffffff; // INT32_MAX
|
|
36
|
+
// S3 hard limit: a multipart upload may have at most 10 000 parts. The
|
|
37
|
+
// server computes the part plan and returns `total_parts`; the SDK trusts
|
|
38
|
+
// that value (Model A) but guards the ceiling so an out-of-contract server
|
|
39
|
+
// response or a chunk-size regression surfaces as a typed error rather than
|
|
40
|
+
// a doomed run of presigned PUTs ending in a rejected /multipart/complete.
|
|
41
|
+
const S3_MAX_MULTIPART_PARTS = 10_000;
|
|
42
|
+
// Contract bound on `MultipartInitiateResponse.recommended_chunk_size`
|
|
43
|
+
// (compression_contracts/openapi api.yaml — `maximum: 104857600`). The
|
|
44
|
+
// minimum is `multipart_chunk_size` (== MULTIPART_CHUNK_SIZE, drift-guarded
|
|
45
|
+
// above). The generated TS `FromJSON` does NO runtime validation (unlike the
|
|
46
|
+
// strict PHP generated model, which rejects out-of-range values at
|
|
47
|
+
// deserialize), so the TS SDK must enforce this range itself — otherwise a
|
|
48
|
+
// malformed/hostile server `recommended_chunk_size` would pass the
|
|
49
|
+
// part-count guard and drive `fileByteSource` into an unbounded
|
|
50
|
+
// `Buffer.allocUnsafe(length)` (the exact memory-blowup class this SDK
|
|
51
|
+
// exists to prevent). codex review (high).
|
|
52
|
+
const RECOMMENDED_CHUNK_SIZE_MAX_BYTES = 104_857_600; // 100 MiB
|
|
15
53
|
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
16
54
|
const DEFAULT_POLL_TIMEOUT_MS = 300_000; // 5 min
|
|
55
|
+
// Statuses that waitForWorkflow() returns immediately on. Per ticket I24,
|
|
56
|
+
// `cancelled` and `expired` are terminal (a workflow cannot leave either
|
|
57
|
+
// state). `paused_insufficient_credits` is a soft-pause: not terminal, but
|
|
58
|
+
// polling blindly is the wrong behaviour because the workflow only resumes
|
|
59
|
+
// on caller action (top-up + resume). The SDK returns immediately so the
|
|
60
|
+
// caller can inspect `pausedDetail` and drive the resume flow.
|
|
17
61
|
const TERMINAL_STATUSES = new Set([
|
|
18
62
|
WorkflowStatus.completed,
|
|
19
63
|
WorkflowStatus.failed,
|
|
20
64
|
WorkflowStatus.partially_failed,
|
|
65
|
+
WorkflowStatus.cancelled,
|
|
66
|
+
WorkflowStatus.expired,
|
|
67
|
+
WorkflowStatus.paused_insufficient_credits,
|
|
21
68
|
]);
|
|
22
69
|
function isValidationDetails(value) {
|
|
23
70
|
return (Array.isArray(value) &&
|
|
71
|
+
value.length > 0 &&
|
|
24
72
|
value.every((el) => typeof el === 'object' &&
|
|
25
73
|
el !== null &&
|
|
26
|
-
typeof el.field === 'string' &&
|
|
27
74
|
typeof el.message === 'string'));
|
|
28
75
|
}
|
|
76
|
+
// An abort may surface as DOMException (browser + modern Node), a plain Error
|
|
77
|
+
// subclass with name='AbortError', or (rarely) a plain object with that name
|
|
78
|
+
// on less conformant runtimes. Match any non-null thing exposing the name.
|
|
79
|
+
function isAbortError(err) {
|
|
80
|
+
return (err !== null &&
|
|
81
|
+
typeof err === 'object' &&
|
|
82
|
+
err.name === 'AbortError');
|
|
83
|
+
}
|
|
84
|
+
// Retryable S3 PUT response statuses: 429 throttling, 503 slow-down, and any
|
|
85
|
+
// other 5xx (502/504 are common transients behind CloudFront/S3). 4xx other
|
|
86
|
+
// than 429 (403 signed-URL expiry, 400 SignatureDoesNotMatch, etc.) are
|
|
87
|
+
// configuration / authority issues — retrying just delays the real failure.
|
|
88
|
+
function isRetryableStatus(status) {
|
|
89
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
90
|
+
}
|
|
91
|
+
// fetch surfaces network failures (DNS, TLS, TCP reset, mid-body disconnect)
|
|
92
|
+
// as TypeError. Abort surfaces as a DOMException with name='AbortError', not
|
|
93
|
+
// a TypeError, so a plain instanceof check is sufficient — abort is filtered
|
|
94
|
+
// before reaching here by the dedicated isAbortError guard in the catch.
|
|
95
|
+
function isRetryableNetworkError(err) {
|
|
96
|
+
return err instanceof TypeError;
|
|
97
|
+
}
|
|
98
|
+
// Full-jitter exponential backoff: delay = random(0, base * 2^attemptIndex).
|
|
99
|
+
// AWS SDK guidance for shared-throttling sources like S3 — keeps competing
|
|
100
|
+
// clients from synchronising their retries.
|
|
101
|
+
function fullJitterDelay(baseMs, attemptIndex) {
|
|
102
|
+
if (baseMs <= 0)
|
|
103
|
+
return 0;
|
|
104
|
+
const ceiling = baseMs * Math.pow(2, attemptIndex);
|
|
105
|
+
return Math.floor(Math.random() * ceiling);
|
|
106
|
+
}
|
|
107
|
+
// Cancel a Response body so undici (Node 18+ fetch) releases the underlying
|
|
108
|
+
// connection promptly instead of waiting for GC. We swallow any error: the
|
|
109
|
+
// retry loop is about to re-PUT the chunk; failing the cleanup must not
|
|
110
|
+
// shadow the real failure that triggered the retry.
|
|
111
|
+
async function drainResponseBody(response) {
|
|
112
|
+
try {
|
|
113
|
+
await response.body?.cancel();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* ignore */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Reject NaN/Infinity and floor at 1 attempt. A misconfigured 0/negative
|
|
120
|
+
// still attempts the PUT once (so callers see the underlying error rather
|
|
121
|
+
// than a silent zero-PUT no-op).
|
|
122
|
+
function sanitiseAttempts(value, fallback) {
|
|
123
|
+
if (value === undefined || !Number.isFinite(value))
|
|
124
|
+
return fallback;
|
|
125
|
+
return Math.max(1, Math.floor(value));
|
|
126
|
+
}
|
|
127
|
+
// Reject NaN/Infinity and clamp at 0. `0` is permitted so callers can opt out
|
|
128
|
+
// of backoff entirely (e.g. for fast-path tests); `Infinity` would otherwise
|
|
129
|
+
// stall the retry loop indefinitely on the very first backoff.
|
|
130
|
+
function sanitiseBaseMs(value, fallback) {
|
|
131
|
+
if (value === undefined || !Number.isFinite(value))
|
|
132
|
+
return fallback;
|
|
133
|
+
return Math.max(0, Math.floor(value));
|
|
134
|
+
}
|
|
135
|
+
// Reject NaN/Infinity and snap to fallback for any value below 1. Diverges
|
|
136
|
+
// from sanitiseAttempts (which floors at 1) because zero workers here is
|
|
137
|
+
// not a "fail-fast once" semantic — `Math.min(0, queue.length) = 0` at the
|
|
138
|
+
// worker fan-out site below produces zero S3 PUTs, so /multipart/complete
|
|
139
|
+
// is then called with an incomplete `parts` array (silent corruption).
|
|
140
|
+
// Garbage input almost certainly meant "use the default", not "send no parts".
|
|
141
|
+
function sanitiseConcurrency(value, fallback) {
|
|
142
|
+
if (value === undefined || !Number.isFinite(value))
|
|
143
|
+
return fallback;
|
|
144
|
+
const floored = Math.floor(value);
|
|
145
|
+
return floored < 1 ? fallback : floored;
|
|
146
|
+
}
|
|
147
|
+
// Cancellable sleep. Resolves after `ms` ms, rejects with GislAbortError if
|
|
148
|
+
// the caller's signal aborts, or resolves early (without throwing) if the
|
|
149
|
+
// internal `wakeSignal` fires — that path lets a sibling worker's terminal
|
|
150
|
+
// failure short-circuit a peer's backoff sleep without producing a spurious
|
|
151
|
+
// abort error in the peer's own throw stack.
|
|
152
|
+
function sleepWithEitherSignal(ms, abortSignal, wakeSignal) {
|
|
153
|
+
if (abortSignal?.aborted) {
|
|
154
|
+
return Promise.reject(new GislAbortError('Multipart upload aborted'));
|
|
155
|
+
}
|
|
156
|
+
if (wakeSignal.aborted || ms <= 0)
|
|
157
|
+
return Promise.resolve();
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const cleanup = () => {
|
|
160
|
+
abortSignal?.removeEventListener('abort', onAbort);
|
|
161
|
+
wakeSignal.removeEventListener('abort', onWake);
|
|
162
|
+
};
|
|
163
|
+
const onAbort = () => {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
cleanup();
|
|
166
|
+
reject(new GislAbortError('Multipart upload aborted'));
|
|
167
|
+
};
|
|
168
|
+
const onWake = () => {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
cleanup();
|
|
171
|
+
resolve();
|
|
172
|
+
};
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
cleanup();
|
|
175
|
+
resolve();
|
|
176
|
+
}, ms);
|
|
177
|
+
abortSignal?.addEventListener('abort', onAbort, { once: true });
|
|
178
|
+
wakeSignal.addEventListener('abort', onWake, { once: true });
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// Wire an optional external AbortSignal onto an internal per-request controller
|
|
182
|
+
// so either source trips the composed fetch. The `onExternalAbort` callback
|
|
183
|
+
// fires the moment the external signal aborts — callers use it to capture the
|
|
184
|
+
// temporal order of user-vs-timeout causes, so the tiebreak in request() does
|
|
185
|
+
// not rely on `signal.aborted` read at catch time (which flips true regardless
|
|
186
|
+
// of which cause actually fired first).
|
|
187
|
+
//
|
|
188
|
+
// Returns a teardown that removes the listener — must be called in a finally
|
|
189
|
+
// so long-lived user AbortControllers do not accumulate listeners across many
|
|
190
|
+
// uploads. Node 18+ compatible (no AbortSignal.any).
|
|
191
|
+
function bindAbortSignal(external, internal, onExternalAbort) {
|
|
192
|
+
if (!external)
|
|
193
|
+
return () => { };
|
|
194
|
+
if (external.aborted) {
|
|
195
|
+
onExternalAbort?.();
|
|
196
|
+
internal.abort();
|
|
197
|
+
return () => { };
|
|
198
|
+
}
|
|
199
|
+
const onAbort = () => {
|
|
200
|
+
onExternalAbort?.();
|
|
201
|
+
internal.abort();
|
|
202
|
+
};
|
|
203
|
+
external.addEventListener('abort', onAbort, { once: true });
|
|
204
|
+
return () => external.removeEventListener('abort', onAbort);
|
|
205
|
+
}
|
|
206
|
+
// Blob/File input is already lazy: `Blob.slice()` is a zero-copy view and a
|
|
207
|
+
// `File` from a browser picker is disk-backed, so this branch never buffered
|
|
208
|
+
// the whole file. Left structurally identical to the pre-streaming-rewrite
|
|
209
|
+
// behaviour.
|
|
210
|
+
function blobByteSource(blob) {
|
|
211
|
+
return {
|
|
212
|
+
size: blob.size,
|
|
213
|
+
// Pass `blob.type` as the 3rd arg: `Blob.slice()` defaults the slice's
|
|
214
|
+
// content-type to '' otherwise, which would strip the MIME type off the
|
|
215
|
+
// single-shot FormData part (the pre-streaming code appended the original
|
|
216
|
+
// typed Blob directly). Parity fixtures pin this content-type.
|
|
217
|
+
slice: (start, end) => Promise.resolve(blob.slice(start, end, blob.type)),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// File-path input. The pre-rewrite code did `readFileSync(path)` →
|
|
221
|
+
// `new Blob([whole file])`, which (a) OOMs on multi-GB files and (b) cannot
|
|
222
|
+
// even be attempted above ~2 GB because a single libuv `uv_fs_read` is capped
|
|
223
|
+
// at INT32_MAX (see LIBUV_MAX_SINGLE_READ_BYTES). This source instead does a
|
|
224
|
+
// positioned (POSIX pread-semantics) read of ONLY the requested range, with a
|
|
225
|
+
// fresh fd per call so concurrent multipart workers never share a FileHandle
|
|
226
|
+
// (overlapping reads on one handle are unsafe per the Node fs contract) and
|
|
227
|
+
// the fd is always closed in `finally`.
|
|
228
|
+
//
|
|
229
|
+
// Divergence from the old Blob-from-readFileSync behaviour (deliberate, in
|
|
230
|
+
// scope only for streaming): the old path snapshotted the whole file at t0,
|
|
231
|
+
// so every part was point-in-time consistent. Streaming reads each part at
|
|
232
|
+
// the time it is uploaded, so a file truncated/rewritten mid-upload now
|
|
233
|
+
// yields parts from different instants. Truncation is caught by the
|
|
234
|
+
// short-read guard below; full point-in-time snapshotting would require
|
|
235
|
+
// resumable/staged upload and is out of scope (SDK-3, Wb6ebOMM).
|
|
236
|
+
function fileByteSource(path, size) {
|
|
237
|
+
return {
|
|
238
|
+
size,
|
|
239
|
+
async slice(start, end) {
|
|
240
|
+
const length = end - start;
|
|
241
|
+
if (length <= 0)
|
|
242
|
+
return new Blob([]);
|
|
243
|
+
// Per-read tripwire for the libuv INT32_MAX ceiling. Unreachable on the
|
|
244
|
+
// normal path (chunk size <=100 MiB) — exists so a future oversized
|
|
245
|
+
// caller fails here loudly instead of getting a silent short read.
|
|
246
|
+
if (length > LIBUV_MAX_SINGLE_READ_BYTES) {
|
|
247
|
+
throw new GislError(`Refusing to read ${length} bytes in one operation: exceeds the ` +
|
|
248
|
+
`libuv single-read ceiling (${LIBUV_MAX_SINGLE_READ_BYTES}). ` +
|
|
249
|
+
'Reads must be chunked below INT32_MAX.');
|
|
250
|
+
}
|
|
251
|
+
const handle = await open(path, 'r');
|
|
252
|
+
try {
|
|
253
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
254
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
255
|
+
if (bytesRead !== length) {
|
|
256
|
+
// Short read = the file shrank/was truncated under us. Mirrors the
|
|
257
|
+
// PHP SDK's readChunk short-read guard (GislClient.php readChunk).
|
|
258
|
+
throw new GislError(`Short read on ${path}: expected ${length} bytes at offset ` +
|
|
259
|
+
`${start}, got ${bytesRead}. File changed during upload.`);
|
|
260
|
+
}
|
|
261
|
+
return new Blob([buffer]);
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
await handle.close();
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
29
269
|
export class GislClient {
|
|
30
270
|
baseUrl;
|
|
31
271
|
headers;
|
|
32
272
|
timeoutMs;
|
|
33
273
|
multipartThreshold;
|
|
34
274
|
multipartConcurrency;
|
|
275
|
+
multipartMaxAttempts;
|
|
276
|
+
multipartRetryBaseMs;
|
|
277
|
+
useSessionCookie;
|
|
35
278
|
constructor(config) {
|
|
36
279
|
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
37
280
|
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
281
|
+
this.useSessionCookie = config.useSessionCookie ?? false;
|
|
38
282
|
// Floor the threshold at the first-chunk size: the multipart initiate
|
|
39
283
|
// must always carry an 8MB chunk, so routing a sub-8MB file into the
|
|
40
284
|
// multipart path would violate the contract.
|
|
41
|
-
this.multipartThreshold = Math.max(config.multipartThreshold ??
|
|
42
|
-
this.multipartConcurrency = config.multipartConcurrency
|
|
285
|
+
this.multipartThreshold = Math.max(config.multipartThreshold ?? SINGLE_SHOT_MAX_BYTES, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
|
|
286
|
+
this.multipartConcurrency = sanitiseConcurrency(config.multipartConcurrency, MULTIPART_CONCURRENCY_DEFAULT);
|
|
287
|
+
// Sanitise: reject NaN/Infinity (the former would cause `attempt < NaN`
|
|
288
|
+
// to be perpetually false, skipping every PUT; the latter would retry
|
|
289
|
+
// unboundedly). Floor at 1 so a misconfigured 0/negative still attempts
|
|
290
|
+
// once.
|
|
291
|
+
this.multipartMaxAttempts = sanitiseAttempts(config.multipartMaxAttempts, DEFAULT_MULTIPART_MAX_ATTEMPTS);
|
|
292
|
+
this.multipartRetryBaseMs = sanitiseBaseMs(config.multipartRetryBaseMs, DEFAULT_MULTIPART_RETRY_BASE_MS);
|
|
43
293
|
this.headers = { ...config.headers };
|
|
44
294
|
if (config.apiKey) {
|
|
45
295
|
this.headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
@@ -49,8 +299,12 @@ export class GislClient {
|
|
|
49
299
|
// Internal HTTP
|
|
50
300
|
// -----------------------------------------------------------------------
|
|
51
301
|
async request(method, path, opts = {}) {
|
|
302
|
+
// Fast-fail on a pre-aborted user signal before building the request.
|
|
303
|
+
if (opts.signal?.aborted) {
|
|
304
|
+
throw new GislAbortError(`Request to ${method} ${path} aborted`);
|
|
305
|
+
}
|
|
52
306
|
const url = `${this.baseUrl}${path}`;
|
|
53
|
-
const headers = { ...this.headers };
|
|
307
|
+
const headers = { ...this.headers, ...opts.headers };
|
|
54
308
|
let body;
|
|
55
309
|
if (opts.json !== false && opts.body && !(opts.body instanceof FormData)) {
|
|
56
310
|
headers['Content-Type'] = 'application/json';
|
|
@@ -60,7 +314,22 @@ export class GislClient {
|
|
|
60
314
|
body = opts.body;
|
|
61
315
|
}
|
|
62
316
|
const controller = new AbortController();
|
|
63
|
-
|
|
317
|
+
// Record the first cause that tripped the composed controller. Checking
|
|
318
|
+
// `opts.signal.aborted` alone at catch time is not sound: if the timer
|
|
319
|
+
// fires first and the user signal aborts microseconds later (before
|
|
320
|
+
// `catch` runs), that flag is also true — but the true cause was the
|
|
321
|
+
// timeout. Capturing which side fired first gives a deterministic
|
|
322
|
+
// classification regardless of scheduling.
|
|
323
|
+
let firstCause = null;
|
|
324
|
+
const timer = setTimeout(() => {
|
|
325
|
+
if (firstCause === null)
|
|
326
|
+
firstCause = 'timeout';
|
|
327
|
+
controller.abort();
|
|
328
|
+
}, this.timeoutMs);
|
|
329
|
+
const unbind = bindAbortSignal(opts.signal, controller, () => {
|
|
330
|
+
if (firstCause === null)
|
|
331
|
+
firstCause = 'user';
|
|
332
|
+
});
|
|
64
333
|
let response;
|
|
65
334
|
try {
|
|
66
335
|
response = await fetch(url, {
|
|
@@ -68,20 +337,35 @@ export class GislClient {
|
|
|
68
337
|
headers,
|
|
69
338
|
body,
|
|
70
339
|
signal: controller.signal,
|
|
340
|
+
// `credentials: 'include'` on every request when the consumer opts
|
|
341
|
+
// into cookie-based auth (Symfony session via /api/auth/login).
|
|
342
|
+
// No-op in Node (fetch ignores the field there); mandatory for
|
|
343
|
+
// cross-origin browser SPAs to send the session cookie.
|
|
344
|
+
...(this.useSessionCookie ? { credentials: 'include' } : {}),
|
|
71
345
|
});
|
|
72
346
|
}
|
|
73
347
|
catch (err) {
|
|
74
|
-
if (err
|
|
348
|
+
if (isAbortError(err)) {
|
|
349
|
+
if (firstCause === 'user') {
|
|
350
|
+
throw new GislAbortError(`Request to ${method} ${path} aborted`);
|
|
351
|
+
}
|
|
75
352
|
throw new GislTimeoutError(`Request to ${method} ${path} timed out after ${this.timeoutMs}ms`);
|
|
76
353
|
}
|
|
77
354
|
throw err;
|
|
78
355
|
}
|
|
79
356
|
finally {
|
|
80
357
|
clearTimeout(timer);
|
|
358
|
+
unbind();
|
|
81
359
|
}
|
|
82
360
|
if (opts.rawResponse) {
|
|
83
361
|
return response;
|
|
84
362
|
}
|
|
363
|
+
// 204 No Content — contracted success status for endpoints that return
|
|
364
|
+
// no body (e.g. POST /api/contact). Short-circuit before handleResponse
|
|
365
|
+
// so an empty body never trips the JSON parser.
|
|
366
|
+
if (response.status === 204) {
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
85
369
|
return this.handleResponse(response, path, opts.deserialize);
|
|
86
370
|
}
|
|
87
371
|
async handleResponse(response, path, deserialize) {
|
|
@@ -93,6 +377,10 @@ export class GislClient {
|
|
|
93
377
|
}
|
|
94
378
|
return undefined;
|
|
95
379
|
}
|
|
380
|
+
// Wire-side fields are snake_case (raw response.json() — never run through
|
|
381
|
+
// FromJSON helpers here). The structured-error subclasses receive a typed
|
|
382
|
+
// payload built via the per-envelope FromJSON helper, which handles the
|
|
383
|
+
// snake_case -> camelCase conversion for nested fields.
|
|
96
384
|
let json;
|
|
97
385
|
try {
|
|
98
386
|
json = await response.json();
|
|
@@ -100,23 +388,149 @@ export class GislClient {
|
|
|
100
388
|
catch {
|
|
101
389
|
throw new GislApiError(response.status, 'Invalid JSON response', path);
|
|
102
390
|
}
|
|
103
|
-
// Schema endpoint returns raw JSON (no envelope)
|
|
104
|
-
if (path === '/api/operations/schema') {
|
|
105
|
-
if (!response.ok) {
|
|
106
|
-
throw new GislApiError(response.status, json.error ?? 'Unknown error', path);
|
|
107
|
-
}
|
|
108
|
-
return deserialize ? deserialize(json) : json;
|
|
109
|
-
}
|
|
110
391
|
// Standard envelope: { success, data } or { success, error, details }
|
|
111
392
|
if (!response.ok || json.success === false) {
|
|
393
|
+
// Localisation triple per ticket I26 — surfaced on every typed error so
|
|
394
|
+
// consumers can drive client-side i18n catalogs without unwrapping the
|
|
395
|
+
// typed payload. Field names are wire snake_case here; the typed payload
|
|
396
|
+
// (built via FromJSON below) carries camelCase copies.
|
|
397
|
+
const i18n = {
|
|
398
|
+
messageKey: json.message_key,
|
|
399
|
+
locale: json.locale,
|
|
400
|
+
messageParams: json.message_params,
|
|
401
|
+
};
|
|
402
|
+
// Validation-details branch first — preserve existing shape so callers
|
|
403
|
+
// matching on `instanceof GislValidationError` keep working.
|
|
112
404
|
if (isValidationDetails(json.details)) {
|
|
113
|
-
throw new GislValidationError(response.status, json.error ?? 'Validation error', json.details, path);
|
|
405
|
+
throw new GislValidationError(response.status, json.error ?? 'Validation error', json.details, path, i18n);
|
|
114
406
|
}
|
|
115
|
-
|
|
407
|
+
// Dispatch by (status, error_type) onto the structured envelope shapes
|
|
408
|
+
// emitted by the v2 contracts. Each branch builds the typed payload via
|
|
409
|
+
// the generated FromJSON helper so consumers reading e.g.
|
|
410
|
+
// `error.payload.errorType` see camelCase fields rather than the raw
|
|
411
|
+
// wire snake_case.
|
|
412
|
+
//
|
|
413
|
+
// Defense-in-depth: if a malformed wire envelope causes the FromJSON
|
|
414
|
+
// helper to throw or coerce a required field to a sentinel value
|
|
415
|
+
// (e.g. `expired_at` missing -> `new Date(undefined)` => Invalid Date),
|
|
416
|
+
// fall through to the base `GislApiError` rather than handing the
|
|
417
|
+
// caller silently-corrupted typed metadata.
|
|
418
|
+
const errorType = json.error_type;
|
|
419
|
+
const status = response.status;
|
|
420
|
+
const errorMessage = json.error ?? 'Unknown error';
|
|
421
|
+
// Build the typed payload via FromJSON, then validate that all
|
|
422
|
+
// required typed fields are well-formed. FromJSON does not throw on
|
|
423
|
+
// missing required fields — for example `workflow_expired` without
|
|
424
|
+
// `expired_at` produces `new Date(undefined)` => Invalid Date with
|
|
425
|
+
// `getTime() === NaN`. Without an explicit validity check the error
|
|
426
|
+
// would surface a silently-corrupted typed payload instead of falling
|
|
427
|
+
// through to the generic base class.
|
|
428
|
+
const tryThrowStructured = (construct, ErrorClass, validate) => {
|
|
429
|
+
let payload;
|
|
430
|
+
try {
|
|
431
|
+
payload = construct(json);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
if (validate && !validate(payload)) {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
throw new ErrorClass(status, errorMessage, payload, path, i18n);
|
|
440
|
+
};
|
|
441
|
+
const isValidDate = (d) => d instanceof Date && !Number.isNaN(d.getTime());
|
|
442
|
+
if (status === 401 || status === 403) {
|
|
443
|
+
if (errorType && this.isAuthErrorType(errorType)) {
|
|
444
|
+
tryThrowStructured(AuthErrorResponseFromJSON, GislAuthError, (p) => typeof p.errorType === 'string');
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const isInEnum = (value, members) => typeof value === 'string' && Object.values(members).includes(value);
|
|
448
|
+
const isFeatureViolation = (v) => typeof v === 'object' && v !== null
|
|
449
|
+
&& typeof v.feature === 'string';
|
|
450
|
+
if (status === 402 && errorType === 'balance_exhausted') {
|
|
451
|
+
tryThrowStructured(BalanceExhaustedResponseFromJSON, GislBalanceExhaustedError, (p) => isInEnum(p.requiredAction, BalanceExhaustedResponseRequiredActionEnum));
|
|
452
|
+
}
|
|
453
|
+
if (status === 403 && errorType === 'tier_restriction') {
|
|
454
|
+
tryThrowStructured(TierRestrictionResponseFromJSON, GislTierRestrictedError, (p) => isInEnum(p.restrictionKind, TierRestrictionKind)
|
|
455
|
+
&& isInEnum(p.currentTier, UserTier));
|
|
456
|
+
}
|
|
457
|
+
if (status === 403 && errorType === 'feature_tier_restricted') {
|
|
458
|
+
tryThrowStructured(FeatureTierRestrictedResponseFromJSON, GislFeatureTierRestrictedError, (p) => Array.isArray(p.violations) && p.violations.every(isFeatureViolation));
|
|
459
|
+
}
|
|
460
|
+
if (status === 422 && errorType === 'feature_not_available') {
|
|
461
|
+
tryThrowStructured(FeatureNotAvailableResponseFromJSON, GislFeatureNotAvailableError, (p) => Array.isArray(p.violations) && p.violations.every(isFeatureViolation));
|
|
462
|
+
}
|
|
463
|
+
if (status === 422 && errorType === 'workflow_expired') {
|
|
464
|
+
tryThrowStructured(WorkflowExpiredResponseFromJSON, GislWorkflowExpiredError, (p) => isValidDate(p.expiredAt));
|
|
465
|
+
}
|
|
466
|
+
// Upload cap errors. `GislUploadCapExceededError` takes an extra `kind`
|
|
467
|
+
// arg so it cannot use `tryThrowStructured` (whose ErrorClass signature
|
|
468
|
+
// is fixed) — this local helper applies the SAME defense-in-depth
|
|
469
|
+
// discipline: construct via FromJSON, validate required typed fields,
|
|
470
|
+
// fall through to the generic `GislApiError` on any malformed envelope.
|
|
471
|
+
const tryThrowCap = (construct, kind, validate) => {
|
|
472
|
+
let payload;
|
|
473
|
+
try {
|
|
474
|
+
payload = construct(json);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
if (!validate(payload)) {
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
throw new GislUploadCapExceededError(status, errorMessage, kind, payload, path, i18n);
|
|
483
|
+
};
|
|
484
|
+
if (status === 422 && errorType === 'upload_size_exceeds_tier') {
|
|
485
|
+
tryThrowCap(UploadSizeExceedsTierResponseFromJSON, 'size_tier', (p) => isInEnum(p.currentTier, UserTier) &&
|
|
486
|
+
typeof p.maxSizeBytes === 'number');
|
|
487
|
+
}
|
|
488
|
+
if (status === 422 && errorType === 'upload_duration_exceeds_tier') {
|
|
489
|
+
tryThrowCap(UploadDurationExceedsTierResponseFromJSON, 'duration_tier', (p) => isInEnum(p.currentTier, UserTier) &&
|
|
490
|
+
typeof p.maxDurationSeconds === 'number');
|
|
491
|
+
}
|
|
492
|
+
// 413 = the absolute across-tier cap. The contract models 413 as a
|
|
493
|
+
// plain `ErrorEnvelope` (no `error_type` discriminator, no typed
|
|
494
|
+
// payload — api.yaml), so dispatch purely on status with no FromJSON
|
|
495
|
+
// and an undefined payload (the `absolute_413` kind tells the caller
|
|
496
|
+
// there is intentionally no structured envelope to read).
|
|
497
|
+
if (status === 413) {
|
|
498
|
+
throw new GislUploadCapExceededError(status, errorMessage, 'absolute_413', undefined, path, i18n);
|
|
499
|
+
}
|
|
500
|
+
// SDK-3 (Wb6ebOMM) resume-support endpoint error codes. API-2 / PR
|
|
501
|
+
// #283 specced these as plain `ErrorEnvelope` envelopes with the
|
|
502
|
+
// discriminating string on `error_type`. No typed payload to build —
|
|
503
|
+
// dispatch on the (status, error_type) tuple. The HxUmVr3Y contract
|
|
504
|
+
// regen will produce typed responses for these; today the 3 typed
|
|
505
|
+
// subclasses carry only the localisation triple + raw envelope.
|
|
506
|
+
if (status === 404 && errorType === 'MULTIPART_SESSION_NOT_FOUND') {
|
|
507
|
+
throw new GislMultipartSessionNotFoundError(status, errorMessage, path, i18n);
|
|
508
|
+
}
|
|
509
|
+
if (status === 403 && errorType === 'MULTIPART_SESSION_OWNERSHIP') {
|
|
510
|
+
throw new GislMultipartSessionOwnershipError(status, errorMessage, path, i18n);
|
|
511
|
+
}
|
|
512
|
+
if (status === 403 && errorType === 'MULTIPART_SESSION_AUTH_REQUIRED') {
|
|
513
|
+
throw new GislMultipartSessionAuthRequiredError(status, errorMessage, path, i18n);
|
|
514
|
+
}
|
|
515
|
+
// 422 `FILE_TOO_LARGE_FOR_MULTIPART` — pre-S3 capacity reject on the
|
|
516
|
+
// resume-support presign endpoint (more parts than the manifest can
|
|
517
|
+
// ever accept). No typed payload today (the contract carries no
|
|
518
|
+
// structured response for this code); `cap_v2_multipart` discriminant
|
|
519
|
+
// is documented on `GislUploadCapKind`.
|
|
520
|
+
if (status === 422 && errorType === 'FILE_TOO_LARGE_FOR_MULTIPART') {
|
|
521
|
+
throw new GislUploadCapExceededError(status, errorMessage, 'cap_v2_multipart', undefined, path, i18n);
|
|
522
|
+
}
|
|
523
|
+
throw new GislApiError(status, errorMessage, path, json.details, { ...i18n, payload: json });
|
|
116
524
|
}
|
|
117
525
|
const data = json.data ?? json;
|
|
118
526
|
return deserialize ? deserialize(data) : data;
|
|
119
527
|
}
|
|
528
|
+
// Membership check for the AuthErrorType discriminator. Reads the generated
|
|
529
|
+
// enum object directly so a future contract addition lands here without a
|
|
530
|
+
// hand-edit. The bundle cost is one tiny `as const` literal map (8 entries).
|
|
531
|
+
isAuthErrorType(value) {
|
|
532
|
+
return Object.values(AuthErrorType).includes(value);
|
|
533
|
+
}
|
|
120
534
|
// -----------------------------------------------------------------------
|
|
121
535
|
// Upload
|
|
122
536
|
// -----------------------------------------------------------------------
|
|
@@ -128,33 +542,60 @@ export class GislClient {
|
|
|
128
542
|
* @param options Upload options including progress callback.
|
|
129
543
|
*/
|
|
130
544
|
async uploadFile(file, options) {
|
|
131
|
-
|
|
545
|
+
// Pre-abort check: bail before touching the filesystem when the caller
|
|
546
|
+
// has already cancelled.
|
|
547
|
+
if (options?.signal?.aborted) {
|
|
548
|
+
throw new GislAbortError('Upload aborted before start');
|
|
549
|
+
}
|
|
550
|
+
let source;
|
|
132
551
|
let fileName;
|
|
133
|
-
let fileSize;
|
|
134
552
|
if (typeof file === 'string') {
|
|
135
|
-
|
|
136
|
-
|
|
553
|
+
// `stat` for the size only — the bytes are NEVER read up front. The old
|
|
554
|
+
// path did `readFileSync(file)` which OOMs on multi-GB files and is
|
|
555
|
+
// impossible above the libuv INT32_MAX single-read ceiling regardless
|
|
556
|
+
// of available memory (see fileByteSource / LIBUV_MAX_SINGLE_READ_BYTES).
|
|
557
|
+
const stats = await stat(file);
|
|
137
558
|
fileName = basename(file);
|
|
138
|
-
|
|
139
|
-
blob = new Blob([content]);
|
|
559
|
+
source = fileByteSource(file, stats.size);
|
|
140
560
|
}
|
|
141
561
|
else {
|
|
142
|
-
blob = file;
|
|
143
562
|
fileName = file.name ?? 'upload';
|
|
144
|
-
|
|
563
|
+
source = blobByteSource(file);
|
|
145
564
|
}
|
|
146
|
-
if (
|
|
147
|
-
|
|
565
|
+
if (typeof options?.resumeUploadId === 'string' && options.resumeUploadId !== '') {
|
|
566
|
+
// SDK-3 (Wb6ebOMM): resume path takes the durable session's
|
|
567
|
+
// `recommended_chunk_size` from the /status envelope rather than
|
|
568
|
+
// the initiate envelope (initiate is skipped). Below the multipart
|
|
569
|
+
// threshold a resume is still meaningful — the original session was
|
|
570
|
+
// started as multipart, so a sub-threshold file CAN'T be a "resume
|
|
571
|
+
// target" in practice. Guard explicitly so a confused caller gets a
|
|
572
|
+
// clear error rather than a 404 on /status.
|
|
573
|
+
if (source.size <= this.multipartThreshold) {
|
|
574
|
+
throw new GislError('uploadFile: resumeUploadId set but file size is at-or-below the multipart ' +
|
|
575
|
+
`threshold (${this.multipartThreshold} bytes); resume targets must be multipart sessions.`);
|
|
576
|
+
}
|
|
577
|
+
return this.multipartResume(source, fileName, source.size, options.resumeUploadId, options);
|
|
148
578
|
}
|
|
149
|
-
|
|
579
|
+
if (source.size > this.multipartThreshold) {
|
|
580
|
+
return this.multipartUpload(source, fileName, source.size, options);
|
|
581
|
+
}
|
|
582
|
+
return this.singleUpload(source, fileName, options);
|
|
150
583
|
}
|
|
151
|
-
async singleUpload(
|
|
584
|
+
async singleUpload(source, fileName, options) {
|
|
152
585
|
const form = new FormData();
|
|
153
|
-
|
|
586
|
+
// Single-shot is gated to <= single_shot_max_bytes (10 MB) by the router
|
|
587
|
+
// above, so this one bounded read is trivially under the libuv ceiling
|
|
588
|
+
// and a non-issue for memory. `slice` returns a Blob (the file-path
|
|
589
|
+
// source wraps the bounded Buffer) so FormData.append is unchanged —
|
|
590
|
+
// multipart never wraps a whole-file Blob, only this <=10 MB single-shot
|
|
591
|
+
// path ever holds a full payload Blob.
|
|
592
|
+
const body = await source.slice(0, source.size);
|
|
593
|
+
form.append('file', body, fileName);
|
|
154
594
|
return this.request('POST', '/api/uploads', {
|
|
155
595
|
body: form,
|
|
156
596
|
json: false,
|
|
157
597
|
deserialize: UploadResponseFromJSON,
|
|
598
|
+
signal: options?.signal,
|
|
158
599
|
});
|
|
159
600
|
}
|
|
160
601
|
/**
|
|
@@ -168,18 +609,31 @@ export class GislClient {
|
|
|
168
609
|
* comes from the initiate response's first-chunk detection; for authoritative
|
|
169
610
|
* post-upload metadata callers should use getMetadata(fileId).
|
|
170
611
|
*/
|
|
171
|
-
async multipartUpload(
|
|
612
|
+
async multipartUpload(source, fileName, totalSize, options) {
|
|
172
613
|
// Step 1: Initiate with first chunk
|
|
173
614
|
const firstChunkSize = Math.min(totalSize, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
|
|
174
|
-
const firstChunk =
|
|
615
|
+
const firstChunk = await source.slice(0, firstChunkSize);
|
|
175
616
|
const initiateForm = new FormData();
|
|
176
617
|
initiateForm.append('file', firstChunk, fileName);
|
|
177
618
|
initiateForm.append('filename', fileName);
|
|
178
619
|
initiateForm.append('total_size', totalSize.toString());
|
|
620
|
+
if (options?.metadataHint !== undefined) {
|
|
621
|
+
// Wire format: a single FormData field carrying the JSON-stringified
|
|
622
|
+
// hint object. Single-shot uploads do not accept this field — see
|
|
623
|
+
// singleUpload() which silently ignores `options.metadataHint`.
|
|
624
|
+
// Route through the generated ToJSON helper so the wire form is the
|
|
625
|
+
// contract-pinned snake_case shape (`duration_seconds`, `width`,
|
|
626
|
+
// `height`). Stringify-ing the camelCase TS object directly would
|
|
627
|
+
// emit `durationSeconds` and the server would silently drop it,
|
|
628
|
+
// defeating the hint's primary use (long-form pre-classification when
|
|
629
|
+
// the first-chunk probe lacks container metadata).
|
|
630
|
+
initiateForm.append('metadata_hint', JSON.stringify(MultipartInitiateRequestMetadataHintToJSON(options.metadataHint)));
|
|
631
|
+
}
|
|
179
632
|
const initResponse = await this.request('POST', '/api/uploads/multipart/initiate', {
|
|
180
633
|
body: initiateForm,
|
|
181
634
|
json: false,
|
|
182
635
|
deserialize: MultipartInitiateResponseFromJSON,
|
|
636
|
+
signal: options?.signal,
|
|
183
637
|
});
|
|
184
638
|
let uploadedBytes = firstChunkSize;
|
|
185
639
|
options?.onProgress?.(uploadedBytes, totalSize);
|
|
@@ -187,44 +641,265 @@ export class GislClient {
|
|
|
187
641
|
const etags = [];
|
|
188
642
|
const presignedUrls = initResponse.presignedUrls;
|
|
189
643
|
const chunkSize = initResponse.recommendedChunkSize;
|
|
644
|
+
// MultipartInitiateResponseFromJSON does NO runtime validation (unlike
|
|
645
|
+
// the strict PHP generated model, which rejects these at deserialize —
|
|
646
|
+
// the documented lax-TS-vs-strict-PHP divergence). The TS SDK must
|
|
647
|
+
// therefore enforce, before any chunk read/PUT, what PHP gets for free
|
|
648
|
+
// from its generated model + its explicit pre-loop guards (codex review):
|
|
649
|
+
//
|
|
650
|
+
// (a) uploadId must be a non-empty string. `FromJSON` assigns
|
|
651
|
+
// `json['upload_id']` directly, so a malformed initiate could
|
|
652
|
+
// otherwise produce a typed GislMultipartPartError whose `uploadId`
|
|
653
|
+
// is `undefined` and synthesise a bogus UploadResponse.fileId —
|
|
654
|
+
// mirrors the PHP pre-loop `is_string && !== ''` guard.
|
|
655
|
+
if (typeof initResponse.uploadId !== 'string' ||
|
|
656
|
+
initResponse.uploadId === '') {
|
|
657
|
+
throw new GislError('Multipart initiate response missing or empty upload_id.');
|
|
658
|
+
}
|
|
659
|
+
// (b) recommendedChunkSize must be a finite number INSIDE the contract
|
|
660
|
+
// range [MULTIPART_CHUNK_SIZE, RECOMMENDED_CHUNK_SIZE_MAX_BYTES]. The
|
|
661
|
+
// old `>= 1` check let a malformed/hostile huge value pass the
|
|
662
|
+
// part-count guard and drive `fileByteSource` into an unbounded
|
|
663
|
+
// `Buffer.allocUnsafe(length)` — the memory-blowup class this SDK
|
|
664
|
+
// exists to prevent. PHP's strict generated model already rejects
|
|
665
|
+
// out-of-range values at deserialize; this is the TS equivalent.
|
|
666
|
+
if (typeof chunkSize !== 'number' ||
|
|
667
|
+
!Number.isInteger(chunkSize) ||
|
|
668
|
+
chunkSize < MULTIPART_CHUNK_SIZE ||
|
|
669
|
+
chunkSize > RECOMMENDED_CHUNK_SIZE_MAX_BYTES) {
|
|
670
|
+
// `Number.isInteger` also rejects NaN/Infinity and a fractional
|
|
671
|
+
// `recommended_chunk_size` (e.g. 5242880.5) that would otherwise
|
|
672
|
+
// reach `Buffer.allocUnsafe(fractional)` and fail later as a
|
|
673
|
+
// misleading part-read error (codex review).
|
|
674
|
+
throw new GislError('Multipart initiate response recommendedChunkSize is missing or ' +
|
|
675
|
+
`outside the contract range [${MULTIPART_CHUNK_SIZE}, ` +
|
|
676
|
+
`${RECOMMENDED_CHUNK_SIZE_MAX_BYTES}]: got ${String(chunkSize)}.`);
|
|
677
|
+
}
|
|
678
|
+
// S3 <=10 000-part ceiling guard (Model A). The server computes and
|
|
679
|
+
// returns `totalParts`; we trust it (consistent with how the SDK already
|
|
680
|
+
// trusts `recommendedChunkSize`/`presignedUrls` from the same envelope)
|
|
681
|
+
// but assert the ceiling, cross-checked against a client-side recompute
|
|
682
|
+
// from the same `chunkSize`. This necessarily fires AFTER the initiate
|
|
683
|
+
// round-trip + 8 MiB first-chunk upload — `totalParts` and `chunkSize`
|
|
684
|
+
// only exist on the initiate response, so a pure pre-flight check is
|
|
685
|
+
// impossible under Model A (this is the card-mandated trade-off).
|
|
686
|
+
const remainingBytes = Math.max(0, totalSize - firstChunkSize);
|
|
687
|
+
const computedParts = 1 + Math.ceil(remainingBytes / chunkSize);
|
|
688
|
+
const serverParts = initResponse.totalParts;
|
|
689
|
+
// `FromJSON` passes `total_parts` through unvalidated. Reject a
|
|
690
|
+
// missing/non-integer value here so the ≤10k guard's
|
|
691
|
+
// `Math.max(serverParts, computedParts)` cannot surface `NaN` in the
|
|
692
|
+
// GislMultipartPartCountError (codex review). Mirrors the uploadId /
|
|
693
|
+
// chunkSize guards above (the lax-TS-vs-strict-PHP-model divergence).
|
|
694
|
+
if (typeof serverParts !== 'number' ||
|
|
695
|
+
!Number.isInteger(serverParts) ||
|
|
696
|
+
serverParts < 1) {
|
|
697
|
+
throw new GislError('Multipart initiate response missing or invalid total_parts: ' +
|
|
698
|
+
`got ${String(serverParts)}.`);
|
|
699
|
+
}
|
|
700
|
+
if (serverParts > S3_MAX_MULTIPART_PARTS ||
|
|
701
|
+
computedParts > S3_MAX_MULTIPART_PARTS) {
|
|
702
|
+
throw new GislMultipartPartCountError(`Upload requires ${Math.max(serverParts, computedParts)} parts, ` +
|
|
703
|
+
`exceeding the S3 ${S3_MAX_MULTIPART_PARTS}-part multipart limit ` +
|
|
704
|
+
`(server reported ${serverParts}, client computed ${computedParts} ` +
|
|
705
|
+
`at ${chunkSize}-byte chunks). A larger chunk size is required ` +
|
|
706
|
+
'server-side to upload a file this large.', Math.max(serverParts, computedParts), S3_MAX_MULTIPART_PARTS);
|
|
707
|
+
}
|
|
708
|
+
// Plan-consistency guard (codex review). The ≤10k ceiling above only
|
|
709
|
+
// bounds the count; it does NOT catch an initiate plan that is internally
|
|
710
|
+
// inconsistent BELOW the cap. Under Model A a contract-compliant server
|
|
711
|
+
// computes `total_parts` from the same `recommended_chunk_size` it
|
|
712
|
+
// returns, and emits exactly one presigned URL per remaining part (part 1
|
|
713
|
+
// is the initiate first chunk). If `total_parts`, the client recompute,
|
|
714
|
+
// and `presigned_urls.length` disagree, proceeding would PUT the wrong
|
|
715
|
+
// number of byte ranges (or wrong offsets) and only fail opaquely at
|
|
716
|
+
// /multipart/complete. Fail fast here with the discrepancy instead.
|
|
717
|
+
if (!Number.isFinite(serverParts) ||
|
|
718
|
+
serverParts !== computedParts ||
|
|
719
|
+
presignedUrls.length !== computedParts - 1) {
|
|
720
|
+
throw new GislError('Multipart initiate plan is internally inconsistent: server ' +
|
|
721
|
+
`total_parts=${serverParts}, client computed ${computedParts} ` +
|
|
722
|
+
`from ${chunkSize}-byte chunks, presigned_urls.length=` +
|
|
723
|
+
`${presignedUrls.length} (expected ${computedParts - 1}). ` +
|
|
724
|
+
'Refusing to upload a mismatched part plan.');
|
|
725
|
+
}
|
|
726
|
+
// Internal abort signal that workers use to short-circuit each others'
|
|
727
|
+
// backoff sleeps. When any worker hits a terminal failure it aborts this
|
|
728
|
+
// controller, which races the caller's signal inside sleepWithSignal so
|
|
729
|
+
// sleeping siblings stop waiting for their timer to expire.
|
|
730
|
+
const failureController = new AbortController();
|
|
731
|
+
// Single PUT attempt. Returns a structured outcome instead of throwing
|
|
732
|
+
// for retryable/non-retryable distinctions, so the caller can decide
|
|
733
|
+
// whether to loop without conflating user-callback errors with
|
|
734
|
+
// network-layer retries (codex review).
|
|
735
|
+
const attemptPut = async (part, chunk, contentLength) => {
|
|
736
|
+
let s3Response;
|
|
737
|
+
try {
|
|
738
|
+
s3Response = await fetch(part.url, {
|
|
739
|
+
method: 'PUT',
|
|
740
|
+
body: chunk,
|
|
741
|
+
headers: { 'Content-Length': contentLength.toString() },
|
|
742
|
+
signal: options?.signal,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
if (isAbortError(err) && options?.signal?.aborted) {
|
|
747
|
+
return {
|
|
748
|
+
kind: 'fatal',
|
|
749
|
+
err: new GislAbortError(`S3 part ${part.partNumber} upload aborted`),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
if (isRetryableNetworkError(err)) {
|
|
753
|
+
return { kind: 'retryable', lastErr: err };
|
|
754
|
+
}
|
|
755
|
+
return { kind: 'fatal', err };
|
|
756
|
+
}
|
|
757
|
+
if (s3Response.ok) {
|
|
758
|
+
const etag = s3Response.headers.get('etag');
|
|
759
|
+
if (!etag) {
|
|
760
|
+
// Drain the body even though we're failing fast — keeps the
|
|
761
|
+
// connection released eagerly.
|
|
762
|
+
await drainResponseBody(s3Response);
|
|
763
|
+
return {
|
|
764
|
+
kind: 'fatal',
|
|
765
|
+
err: new GislError(`S3 response missing ETag for part ${part.partNumber}`),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
return { kind: 'ok', etag };
|
|
769
|
+
}
|
|
770
|
+
// Non-OK: drain the body in BOTH branches before deciding. Undici
|
|
771
|
+
// holds the connection open until the body is consumed regardless of
|
|
772
|
+
// whether we retry.
|
|
773
|
+
await drainResponseBody(s3Response);
|
|
774
|
+
if (!isRetryableStatus(s3Response.status)) {
|
|
775
|
+
return {
|
|
776
|
+
kind: 'fatal',
|
|
777
|
+
err: new GislError(`S3 chunk upload failed for part ${part.partNumber}: ${s3Response.status}`),
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
return {
|
|
781
|
+
kind: 'retryable',
|
|
782
|
+
lastErr: new GislError(`S3 chunk upload failed for part ${part.partNumber}: ${s3Response.status}`),
|
|
783
|
+
};
|
|
784
|
+
};
|
|
190
785
|
const uploadChunk = async (index) => {
|
|
191
786
|
const part = presignedUrls[index];
|
|
192
787
|
const start = firstChunkSize + index * chunkSize;
|
|
193
788
|
const end = Math.min(start + chunkSize, totalSize);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
789
|
+
// Read this part's bytes ONCE here, then reuse the captured chunk
|
|
790
|
+
// across every retry attempt below — so a retry never re-reads the
|
|
791
|
+
// file and the re-PUT is byte-identical (S3 parts are idempotent by
|
|
792
|
+
// partNumber; a re-PUT overwrites, no duplicate-data risk).
|
|
793
|
+
//
|
|
794
|
+
// Live-file caveat (streaming divergence from the old
|
|
795
|
+
// readFileSync→Blob path): the old code snapshotted the whole file at
|
|
796
|
+
// t0 so every part was point-in-time consistent. Streaming reads each
|
|
797
|
+
// part at the instant it is first uploaded, so a file mutated
|
|
798
|
+
// mid-upload yields parts from different instants. Truncation is
|
|
799
|
+
// caught by fileByteSource's short-read guard; full point-in-time
|
|
800
|
+
// snapshotting is resumable/staged-upload territory (SDK-3, Wb6ebOMM).
|
|
801
|
+
// Surface a read failure for THIS part as the typed
|
|
802
|
+
// GislMultipartPartError (with partNumber + uploadId), consistent with
|
|
803
|
+
// the PUT-failure path below — a bare GislError from fileByteSource
|
|
804
|
+
// (short read / libuv ceiling) would otherwise lose the per-part
|
|
805
|
+
// context (codex review). An abort must stay GislAbortError.
|
|
806
|
+
let chunk;
|
|
807
|
+
try {
|
|
808
|
+
chunk = await source.slice(start, end);
|
|
809
|
+
}
|
|
810
|
+
catch (err) {
|
|
811
|
+
if (err instanceof GislAbortError)
|
|
812
|
+
throw err;
|
|
813
|
+
throw new GislMultipartPartError(`Failed to read bytes for part ${part.partNumber}: ` +
|
|
814
|
+
(err instanceof Error ? err.message : String(err)), part.partNumber, initResponse.uploadId);
|
|
202
815
|
}
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
816
|
+
const contentLength = end - start;
|
|
817
|
+
let lastErr = null;
|
|
818
|
+
for (let attempt = 0; attempt < this.multipartMaxAttempts; attempt++) {
|
|
819
|
+
if (options?.signal?.aborted) {
|
|
820
|
+
throw new GislAbortError(`S3 part ${part.partNumber} upload aborted`);
|
|
821
|
+
}
|
|
822
|
+
if (failureController.signal.aborted) {
|
|
823
|
+
// Sibling worker hit a terminal failure: bail before dispatching a
|
|
824
|
+
// wasted PUT. The thrown error is swallowed by the outer worker
|
|
825
|
+
// loop — Promise.all has already settled with the first failure.
|
|
826
|
+
throw new GislError(`S3 part ${part.partNumber} upload abandoned after sibling worker failure`);
|
|
827
|
+
}
|
|
828
|
+
const outcome = await attemptPut(part, chunk, contentLength);
|
|
829
|
+
if (outcome.kind === 'ok') {
|
|
830
|
+
// Apply progress side effects OUTSIDE the retry-scoped path so a
|
|
831
|
+
// user-callback throw does not trigger a duplicate PUT (codex
|
|
832
|
+
// review: retrying after a successful PUT would double-record the
|
|
833
|
+
// ETag and re-upload an already accepted part).
|
|
834
|
+
etags.push({ partNumber: part.partNumber, etag: outcome.etag });
|
|
835
|
+
uploadedBytes += contentLength;
|
|
836
|
+
options?.onProgress?.(uploadedBytes, totalSize);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (outcome.kind === 'fatal') {
|
|
840
|
+
throw outcome.err;
|
|
841
|
+
}
|
|
842
|
+
lastErr = outcome.lastErr;
|
|
843
|
+
if (attempt + 1 >= this.multipartMaxAttempts)
|
|
844
|
+
break;
|
|
845
|
+
const delay = fullJitterDelay(this.multipartRetryBaseMs, attempt);
|
|
846
|
+
// Race the caller's signal AND the sibling-failure signal so a
|
|
847
|
+
// worker that fails terminally wakes its sleeping peers instead of
|
|
848
|
+
// forcing them to wait out their backoff timer.
|
|
849
|
+
await sleepWithEitherSignal(delay, options?.signal, failureController.signal);
|
|
206
850
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
options?.onProgress?.(uploadedBytes, totalSize);
|
|
851
|
+
throw new GislMultipartPartError(`S3 chunk upload failed for part ${part.partNumber} after ${this.multipartMaxAttempts} attempts: ` +
|
|
852
|
+
(lastErr instanceof Error ? lastErr.message : String(lastErr)), part.partNumber, initResponse.uploadId);
|
|
210
853
|
};
|
|
211
|
-
// Upload with concurrency limit
|
|
854
|
+
// Upload with concurrency limit. Workers check the signal before pulling
|
|
855
|
+
// the next queue item so a mid-upload abort drains fast without
|
|
856
|
+
// dispatching new chunks. Chunks already in-flight are cancelled via the
|
|
857
|
+
// composed signal passed to fetch above; sleeping siblings are woken via
|
|
858
|
+
// the failureController set below.
|
|
212
859
|
const queue = [...presignedUrls.keys()];
|
|
213
860
|
const workers = Array.from({ length: Math.min(this.multipartConcurrency, queue.length) }, async () => {
|
|
214
|
-
while (queue.length > 0) {
|
|
861
|
+
while (queue.length > 0 && !failureController.signal.aborted) {
|
|
862
|
+
if (options?.signal?.aborted) {
|
|
863
|
+
throw new GislAbortError('Multipart upload aborted');
|
|
864
|
+
}
|
|
215
865
|
const index = queue.shift();
|
|
216
|
-
|
|
866
|
+
try {
|
|
867
|
+
await uploadChunk(index);
|
|
868
|
+
}
|
|
869
|
+
catch (err) {
|
|
870
|
+
// Wake any sibling currently in a backoff sleep, and prevent
|
|
871
|
+
// siblings from picking up further queue items.
|
|
872
|
+
failureController.abort();
|
|
873
|
+
throw err;
|
|
874
|
+
}
|
|
217
875
|
}
|
|
218
876
|
});
|
|
219
877
|
await Promise.all(workers);
|
|
220
878
|
// Step 3: Complete multipart upload.
|
|
221
|
-
|
|
879
|
+
// Build a typed MultipartCompleteRequest and serialise via the generated
|
|
880
|
+
// ToJSON helper — tsc now catches any field-name drift between the SDK
|
|
881
|
+
// and the OpenAPI spec (see contract-drift-fields.test.ts describe 'c').
|
|
882
|
+
etags.sort((a, b) => a.partNumber - b.partNumber);
|
|
883
|
+
const completeRequest = {
|
|
884
|
+
uploadId: initResponse.uploadId,
|
|
885
|
+
parts: etags,
|
|
886
|
+
};
|
|
887
|
+
// MultipartCompleteRequestToJSON's declared return type is the camelCase
|
|
888
|
+
// `MultipartCompleteRequest` interface, but at runtime it returns the
|
|
889
|
+
// snake_case wire object — an openapi-generator v7 quirk. The drift gate
|
|
890
|
+
// for field names lives at `completeRequest: MultipartCompleteRequest`
|
|
891
|
+
// above; the local wire type + runtime sanity check below guard against
|
|
892
|
+
// the remaining hypothetical: a future generator version emitting a
|
|
893
|
+
// different shape without the declared type catching it.
|
|
894
|
+
const wireCompleteBody = MultipartCompleteRequestToJSON(completeRequest);
|
|
895
|
+
if (typeof wireCompleteBody?.upload_id !== 'string' ||
|
|
896
|
+
!Array.isArray(wireCompleteBody?.parts)) {
|
|
897
|
+
throw new GislError('MultipartCompleteRequestToJSON returned an unexpected shape — generator output may have changed.');
|
|
898
|
+
}
|
|
222
899
|
const completeResp = await this.request('POST', '/api/uploads/multipart/complete', {
|
|
223
|
-
body:
|
|
224
|
-
upload_id: initResponse.uploadId,
|
|
225
|
-
parts: etags,
|
|
226
|
-
},
|
|
900
|
+
body: wireCompleteBody,
|
|
227
901
|
deserialize: MultipartCompleteResponseFromJSON,
|
|
902
|
+
signal: options?.signal,
|
|
228
903
|
});
|
|
229
904
|
// Defensive: the status enum currently has only 'completed', but guard
|
|
230
905
|
// against future expansion so an unexpected terminal state doesn't pass
|
|
@@ -236,7 +911,557 @@ export class GislClient {
|
|
|
236
911
|
fileId: completeResp.uploadId,
|
|
237
912
|
originalName: fileName,
|
|
238
913
|
mimeType: initResponse.mimeType,
|
|
239
|
-
|
|
914
|
+
// `totalSize` (from fs.stat / Blob.size) — the streaming path no longer
|
|
915
|
+
// holds a whole-file Blob to read `.size` off.
|
|
916
|
+
sizeBytes: totalSize,
|
|
917
|
+
// Preserved from the initiate response: v2 contract makes
|
|
918
|
+
// `constraintsApplied` a REQUIRED field on UploadResponse, and the
|
|
919
|
+
// multipart/complete endpoint does not re-emit it. The first-chunk probe
|
|
920
|
+
// result on the initiate envelope is the authoritative source.
|
|
921
|
+
constraintsApplied: initResponse.constraintsApplied,
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* SDK-3 (Wb6ebOMM): resume an in-progress multipart upload.
|
|
926
|
+
*
|
|
927
|
+
* Skips `/multipart/initiate` entirely (the original initiate happened in a
|
|
928
|
+
* prior process). Walks `/status` for the authoritative list of recorded
|
|
929
|
+
* parts, re-presigns the missing ones in batches of <=100, PUTs only those,
|
|
930
|
+
* and finalises with `/complete`. Caller's `source` MUST be byte-identical
|
|
931
|
+
* to the originally-uploaded file at the same offsets (parts whose etags
|
|
932
|
+
* don't match server state will fail `/complete`).
|
|
933
|
+
*
|
|
934
|
+
* Re-runs the same `uploadId` / `chunkSize` / `totalParts` / plan-consistency
|
|
935
|
+
* guards as the fresh-upload path (`multipartUpload`), using the /status
|
|
936
|
+
* envelope as the equivalent of the initiate envelope. Reuses the same
|
|
937
|
+
* `failureController` sibling-wake + `drainResponseBody` cleanup discipline
|
|
938
|
+
* as the fresh-upload PUT loop. `onProgress` fires on entry seeded from
|
|
939
|
+
* (uploadedPartNumbers.length * chunkSize) and again after every successful
|
|
940
|
+
* PUT. `onCheckpoint` fires OUTSIDE the retry-scoped path after every
|
|
941
|
+
* successful PUT — a callback-throw must not trigger a duplicate PUT.
|
|
942
|
+
*
|
|
943
|
+
* TODO(HxUmVr3Y): replace inline hand-coded request body marshalling on regen.
|
|
944
|
+
*/
|
|
945
|
+
async multipartResume(source, fileName, totalSize, resumeUploadId, options) {
|
|
946
|
+
// Step 1: Walk /status for the authoritative session state.
|
|
947
|
+
const status = await this.walkUploadStatus(resumeUploadId, {
|
|
948
|
+
signal: options?.signal,
|
|
949
|
+
});
|
|
950
|
+
// Validate the /status envelope shape, mirroring the fresh-upload
|
|
951
|
+
// post-initiate guards (`multipartUpload` lines around the
|
|
952
|
+
// total_parts / recommendedChunkSize / uploadId validation block).
|
|
953
|
+
if (typeof status.uploadId !== 'string' ||
|
|
954
|
+
status.uploadId === '' ||
|
|
955
|
+
status.uploadId !== resumeUploadId) {
|
|
956
|
+
throw new GislError('multipartResume: /status response uploadId does not match resumeUploadId.');
|
|
957
|
+
}
|
|
958
|
+
const chunkSize = status.recommendedChunkSize;
|
|
959
|
+
if (typeof chunkSize !== 'number' ||
|
|
960
|
+
!Number.isInteger(chunkSize) ||
|
|
961
|
+
chunkSize < MULTIPART_CHUNK_SIZE ||
|
|
962
|
+
chunkSize > RECOMMENDED_CHUNK_SIZE_MAX_BYTES) {
|
|
963
|
+
throw new GislError('multipartResume: /status recommendedChunkSize missing or outside the contract ' +
|
|
964
|
+
`range [${MULTIPART_CHUNK_SIZE}, ${RECOMMENDED_CHUNK_SIZE_MAX_BYTES}]: got ${String(chunkSize)}.`);
|
|
965
|
+
}
|
|
966
|
+
if (typeof status.totalParts !== 'number' ||
|
|
967
|
+
!Number.isInteger(status.totalParts) ||
|
|
968
|
+
status.totalParts < 1) {
|
|
969
|
+
throw new GislError(`multipartResume: /status totalParts missing or invalid: got ${String(status.totalParts)}.`);
|
|
970
|
+
}
|
|
971
|
+
if (status.totalParts > S3_MAX_MULTIPART_PARTS) {
|
|
972
|
+
throw new GislMultipartPartCountError(`multipartResume: /status totalParts=${status.totalParts} exceeds the S3 ` +
|
|
973
|
+
`${S3_MAX_MULTIPART_PARTS}-part multipart limit.`, status.totalParts, S3_MAX_MULTIPART_PARTS);
|
|
974
|
+
}
|
|
975
|
+
// Sanity-check the caller's byte source against the server's recorded
|
|
976
|
+
// plan. Mirrors the fresh-upload chunk-plan: part 1 = firstChunkSize
|
|
977
|
+
// (8 MiB), parts 2..totalParts each consume chunkSize bytes (last part
|
|
978
|
+
// may be a short tail). Reject a wrong-file resume here — /complete
|
|
979
|
+
// would otherwise fail on etag mismatch.
|
|
980
|
+
const firstChunkSize = Math.min(totalSize, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
|
|
981
|
+
const expectedMinBytes = firstChunkSize + Math.max(0, status.totalParts - 2) * chunkSize + (status.totalParts > 1 ? 1 : 0);
|
|
982
|
+
const expectedMaxBytes = firstChunkSize + Math.max(0, status.totalParts - 1) * chunkSize;
|
|
983
|
+
if (totalSize < expectedMinBytes || totalSize > expectedMaxBytes) {
|
|
984
|
+
throw new GislError(`multipartResume: caller file size (${totalSize}) does not match the resumed ` +
|
|
985
|
+
`session's recorded plan (totalParts=${status.totalParts}, chunkSize=${chunkSize}, ` +
|
|
986
|
+
`expected ${expectedMinBytes}-${expectedMaxBytes} bytes). Wrong file for this uploadId?`);
|
|
987
|
+
}
|
|
988
|
+
// Step 2: Compute missing parts. Server records `uploadedParts` as the
|
|
989
|
+
// authoritative set; everything in [1, totalParts] not in that set is
|
|
990
|
+
// still-to-upload. Part 1 was uploaded inline at initiate — if it is
|
|
991
|
+
// missing from /status the session is unrecoverable (the server rejects
|
|
992
|
+
// re-presigning part 1 to preserve the recorded etag for /complete).
|
|
993
|
+
const uploaded = new Map();
|
|
994
|
+
for (const p of status.uploadedParts) {
|
|
995
|
+
uploaded.set(p.partNumber, p);
|
|
996
|
+
}
|
|
997
|
+
if (!uploaded.has(1)) {
|
|
998
|
+
throw new GislError('multipartResume: part 1 (initiate first chunk) is missing from /status. ' +
|
|
999
|
+
'Part 1 is sealed at initiate and cannot be re-presigned; this session is unrecoverable. ' +
|
|
1000
|
+
'Start a fresh upload (call uploadFile without resumeUploadId).');
|
|
1001
|
+
}
|
|
1002
|
+
const missingParts = [];
|
|
1003
|
+
for (let n = 2; n <= status.totalParts; n++) {
|
|
1004
|
+
if (!uploaded.has(n))
|
|
1005
|
+
missingParts.push(n);
|
|
1006
|
+
}
|
|
1007
|
+
// Seed uploadedBytes from already-uploaded parts so onProgress reflects
|
|
1008
|
+
// the true resumption point. Server reports authoritative part sizes
|
|
1009
|
+
// via `sizeBytes`; sum those rather than guessing chunkSize * count
|
|
1010
|
+
// (the last part may be a short tail).
|
|
1011
|
+
let uploadedBytes = 0;
|
|
1012
|
+
for (const p of status.uploadedParts) {
|
|
1013
|
+
uploadedBytes += p.sizeBytes;
|
|
1014
|
+
}
|
|
1015
|
+
options?.onProgress?.(uploadedBytes, totalSize);
|
|
1016
|
+
const fireCheckpoint = (extraPartNumber) => {
|
|
1017
|
+
const all = [...uploaded.keys()];
|
|
1018
|
+
if (extraPartNumber !== undefined)
|
|
1019
|
+
all.push(extraPartNumber);
|
|
1020
|
+
all.sort((a, b) => a - b);
|
|
1021
|
+
const state = {
|
|
1022
|
+
uploadId: status.uploadId,
|
|
1023
|
+
totalParts: status.totalParts,
|
|
1024
|
+
uploadedPartNumbers: all,
|
|
1025
|
+
manifestExpiresAt: status.manifestExpiresAt,
|
|
1026
|
+
};
|
|
1027
|
+
// Callback fires OUTSIDE retry-scope. A throw here propagates and
|
|
1028
|
+
// fails the upload but cannot trigger a duplicate PUT.
|
|
1029
|
+
options?.onCheckpoint?.(state);
|
|
1030
|
+
};
|
|
1031
|
+
// Fire an entry checkpoint so callers can persist the resumed state
|
|
1032
|
+
// even before any new PUT lands. Useful when the missing-parts list is
|
|
1033
|
+
// empty (everything already uploaded except /complete) — see below.
|
|
1034
|
+
fireCheckpoint();
|
|
1035
|
+
// Short-circuit: every part is already uploaded. Skip presign + PUT
|
|
1036
|
+
// and go straight to /complete with the etags the server has on file.
|
|
1037
|
+
const newEtags = [];
|
|
1038
|
+
if (missingParts.length === 0) {
|
|
1039
|
+
// No PUTs to run; proceed to /complete below with just the recorded parts.
|
|
1040
|
+
}
|
|
1041
|
+
else {
|
|
1042
|
+
// Step 3: For each batch of <=100 missing parts, re-presign + PUT.
|
|
1043
|
+
// We process batches sequentially (presign call) but PUTs within each
|
|
1044
|
+
// batch run concurrently up to multipartConcurrency, mirroring the
|
|
1045
|
+
// fresh-upload worker-pool semantics.
|
|
1046
|
+
const failureController = new AbortController();
|
|
1047
|
+
const putOne = async (part) => {
|
|
1048
|
+
// Offset math mirrors the fresh-upload path
|
|
1049
|
+
// (`multipartUpload`'s `uploadChunk`): part 1 is the initiate's 8 MiB
|
|
1050
|
+
// first chunk, parts 2..N each consume chunkSize bytes starting at
|
|
1051
|
+
// firstChunkSize. The resume path never PUTs part 1 (rejected
|
|
1052
|
+
// earlier as unrecoverable), so partNumber here is always >= 2.
|
|
1053
|
+
const firstChunkSize = Math.min(totalSize, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
|
|
1054
|
+
const start = firstChunkSize + (part.partNumber - 2) * chunkSize;
|
|
1055
|
+
const end = Math.min(start + chunkSize, totalSize);
|
|
1056
|
+
const contentLength = end - start;
|
|
1057
|
+
let chunk;
|
|
1058
|
+
try {
|
|
1059
|
+
chunk = await source.slice(start, end);
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
if (err instanceof GislAbortError)
|
|
1063
|
+
throw err;
|
|
1064
|
+
throw new GislMultipartPartError(`multipartResume: failed to read bytes for part ${part.partNumber}: ` +
|
|
1065
|
+
(err instanceof Error ? err.message : String(err)), part.partNumber, status.uploadId);
|
|
1066
|
+
}
|
|
1067
|
+
let lastErr = null;
|
|
1068
|
+
for (let attempt = 0; attempt < this.multipartMaxAttempts; attempt++) {
|
|
1069
|
+
if (options?.signal?.aborted) {
|
|
1070
|
+
throw new GislAbortError(`multipartResume: S3 part ${part.partNumber} upload aborted`);
|
|
1071
|
+
}
|
|
1072
|
+
if (failureController.signal.aborted) {
|
|
1073
|
+
throw new GislError(`multipartResume: S3 part ${part.partNumber} upload abandoned after sibling failure`);
|
|
1074
|
+
}
|
|
1075
|
+
let s3Response;
|
|
1076
|
+
try {
|
|
1077
|
+
s3Response = await fetch(part.url, {
|
|
1078
|
+
method: 'PUT',
|
|
1079
|
+
body: chunk,
|
|
1080
|
+
headers: { 'Content-Length': contentLength.toString() },
|
|
1081
|
+
signal: options?.signal,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
catch (err) {
|
|
1085
|
+
if (isAbortError(err) && options?.signal?.aborted) {
|
|
1086
|
+
throw new GislAbortError(`multipartResume: S3 part ${part.partNumber} upload aborted`);
|
|
1087
|
+
}
|
|
1088
|
+
// Non-user-abort AbortError (e.g. transport cleanup) must still
|
|
1089
|
+
// surface as a typed GislError subclass — never as a raw
|
|
1090
|
+
// DOMException — to preserve the "every multipart failure is a
|
|
1091
|
+
// typed GislError" contract (code-reviewer P7).
|
|
1092
|
+
if (isAbortError(err)) {
|
|
1093
|
+
throw new GislMultipartPartError(`multipartResume: S3 part ${part.partNumber} aborted by transport: ` +
|
|
1094
|
+
(err instanceof Error ? err.message : String(err)), part.partNumber, status.uploadId);
|
|
1095
|
+
}
|
|
1096
|
+
if (isRetryableNetworkError(err)) {
|
|
1097
|
+
lastErr = err;
|
|
1098
|
+
if (attempt + 1 >= this.multipartMaxAttempts)
|
|
1099
|
+
break;
|
|
1100
|
+
const delay = fullJitterDelay(this.multipartRetryBaseMs, attempt);
|
|
1101
|
+
await sleepWithEitherSignal(delay, options?.signal, failureController.signal);
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
throw err;
|
|
1105
|
+
}
|
|
1106
|
+
if (s3Response.ok) {
|
|
1107
|
+
const etag = s3Response.headers.get('etag');
|
|
1108
|
+
if (!etag) {
|
|
1109
|
+
await drainResponseBody(s3Response);
|
|
1110
|
+
throw new GislError(`multipartResume: S3 response missing ETag for part ${part.partNumber}`);
|
|
1111
|
+
}
|
|
1112
|
+
// Successful PUT — record etag, apply progress + checkpoint side
|
|
1113
|
+
// effects OUTSIDE the retry-scoped path (mirrors the fresh-upload
|
|
1114
|
+
// discipline at multipartUpload's ok-branch).
|
|
1115
|
+
newEtags.push({ partNumber: part.partNumber, etag });
|
|
1116
|
+
uploaded.set(part.partNumber, {
|
|
1117
|
+
partNumber: part.partNumber,
|
|
1118
|
+
etag,
|
|
1119
|
+
sizeBytes: contentLength,
|
|
1120
|
+
lastModified: new Date().toISOString(),
|
|
1121
|
+
});
|
|
1122
|
+
uploadedBytes = Math.min(uploadedBytes + contentLength, totalSize);
|
|
1123
|
+
options?.onProgress?.(uploadedBytes, totalSize);
|
|
1124
|
+
fireCheckpoint();
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
await drainResponseBody(s3Response);
|
|
1128
|
+
if (!isRetryableStatus(s3Response.status)) {
|
|
1129
|
+
throw new GislError(`multipartResume: S3 chunk upload failed for part ${part.partNumber}: HTTP ${s3Response.status} (non-retryable)`);
|
|
1130
|
+
}
|
|
1131
|
+
lastErr = new GislError(`multipartResume: S3 chunk upload failed for part ${part.partNumber}: HTTP ${s3Response.status}`);
|
|
1132
|
+
if (attempt + 1 >= this.multipartMaxAttempts)
|
|
1133
|
+
break;
|
|
1134
|
+
const delay = fullJitterDelay(this.multipartRetryBaseMs, attempt);
|
|
1135
|
+
await sleepWithEitherSignal(delay, options?.signal, failureController.signal);
|
|
1136
|
+
}
|
|
1137
|
+
throw new GislMultipartPartError(`multipartResume: S3 chunk upload failed for part ${part.partNumber} after ${this.multipartMaxAttempts} attempts: ` +
|
|
1138
|
+
(lastErr instanceof Error ? lastErr.message : String(lastErr)), part.partNumber, status.uploadId);
|
|
1139
|
+
};
|
|
1140
|
+
// Drive batches of <=100 part numbers.
|
|
1141
|
+
const PRESIGN_BATCH_SIZE = 100;
|
|
1142
|
+
for (let i = 0; i < missingParts.length; i += PRESIGN_BATCH_SIZE) {
|
|
1143
|
+
if (options?.signal?.aborted) {
|
|
1144
|
+
throw new GislAbortError('multipartResume aborted');
|
|
1145
|
+
}
|
|
1146
|
+
const batch = missingParts.slice(i, i + PRESIGN_BATCH_SIZE);
|
|
1147
|
+
const presigned = await this.presignParts(status.uploadId, batch, status.totalParts, { signal: options?.signal });
|
|
1148
|
+
// Concurrent PUTs within the batch.
|
|
1149
|
+
const queue = [...presigned.presignedUrls];
|
|
1150
|
+
const workers = Array.from({ length: Math.min(this.multipartConcurrency, queue.length) }, async () => {
|
|
1151
|
+
while (queue.length > 0 && !failureController.signal.aborted) {
|
|
1152
|
+
if (options?.signal?.aborted) {
|
|
1153
|
+
throw new GislAbortError('multipartResume aborted');
|
|
1154
|
+
}
|
|
1155
|
+
const part = queue.shift();
|
|
1156
|
+
try {
|
|
1157
|
+
await putOne(part);
|
|
1158
|
+
}
|
|
1159
|
+
catch (err) {
|
|
1160
|
+
failureController.abort();
|
|
1161
|
+
throw err;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
await Promise.all(workers);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
// Step 4: /complete with the FULL parts list = (server-recorded etags
|
|
1169
|
+
// from /status) ∪ (newly-PUT etags this run). Sort ascending by
|
|
1170
|
+
// partNumber (the wire shape pin in fresh-upload mirrors this).
|
|
1171
|
+
const allParts = [];
|
|
1172
|
+
for (const p of status.uploadedParts) {
|
|
1173
|
+
allParts.push({ partNumber: p.partNumber, etag: p.etag });
|
|
1174
|
+
}
|
|
1175
|
+
for (const e of newEtags)
|
|
1176
|
+
allParts.push(e);
|
|
1177
|
+
allParts.sort((a, b) => a.partNumber - b.partNumber);
|
|
1178
|
+
if (allParts.length !== status.totalParts) {
|
|
1179
|
+
throw new GislError(`multipartResume: assembled parts list has ${allParts.length} entries, ` +
|
|
1180
|
+
`expected ${status.totalParts}. Refusing to /complete with an incomplete part set.`);
|
|
1181
|
+
}
|
|
1182
|
+
// Marshal via the generator's `*ToJSON` helper so the contracts-drift
|
|
1183
|
+
// guard test (`contract-drift-fields.test.ts`) covers BOTH the fresh and
|
|
1184
|
+
// resume paths uniformly (code-reviewer P7). If a future regen adds a
|
|
1185
|
+
// required field to `MultipartCompleteRequest`, tsc fails here at the
|
|
1186
|
+
// typed object literal — same as the fresh path.
|
|
1187
|
+
const completeRequest = {
|
|
1188
|
+
uploadId: status.uploadId,
|
|
1189
|
+
parts: allParts.map((p) => ({ partNumber: p.partNumber, etag: p.etag })),
|
|
1190
|
+
};
|
|
1191
|
+
const wireCompleteBody = MultipartCompleteRequestToJSON(completeRequest);
|
|
1192
|
+
if (typeof wireCompleteBody?.upload_id !== 'string' ||
|
|
1193
|
+
!Array.isArray(wireCompleteBody?.parts)) {
|
|
1194
|
+
throw new GislError('multipartResume: MultipartCompleteRequestToJSON returned an unexpected shape.');
|
|
1195
|
+
}
|
|
1196
|
+
const completeResp = await this.request('POST', '/api/uploads/multipart/complete', {
|
|
1197
|
+
body: wireCompleteBody,
|
|
1198
|
+
deserialize: MultipartCompleteResponseFromJSON,
|
|
1199
|
+
signal: options?.signal,
|
|
1200
|
+
});
|
|
1201
|
+
if (completeResp.status !== 'completed') {
|
|
1202
|
+
throw new GislError(`multipartResume: completed with unexpected status: ${completeResp.status}`);
|
|
1203
|
+
}
|
|
1204
|
+
// Resume-path information loss: the /status envelope (and /complete)
|
|
1205
|
+
// do NOT carry `mime_type` or `constraints_applied` — those were
|
|
1206
|
+
// emitted on the original initiate envelope, which the resume path
|
|
1207
|
+
// skipped. Fall back to caller-supplied `fileName` for `originalName`;
|
|
1208
|
+
// emit `mimeType` as `''` and `constraintsApplied` as a sentinel
|
|
1209
|
+
// populated with the only fact we DO know on resume: `maxSizeBytes =
|
|
1210
|
+
// totalSize` (the upload was permitted at this size when initiated),
|
|
1211
|
+
// `processingClassPreAssignment = 'unknown'`. Consumers needing
|
|
1212
|
+
// authoritative post-upload metadata SHOULD call `getMetadata(fileId)`
|
|
1213
|
+
// (the fresh-upload path's docblock already says the same).
|
|
1214
|
+
// TODO(HxUmVr3Y): when contracts ships the resume-support schemas,
|
|
1215
|
+
// extend `/status` (or add `/multipart/{id}/manifest`) to carry
|
|
1216
|
+
// mime_type + constraints_applied so this sentinel can go away.
|
|
1217
|
+
return {
|
|
1218
|
+
fileId: completeResp.uploadId,
|
|
1219
|
+
originalName: fileName,
|
|
1220
|
+
mimeType: '',
|
|
1221
|
+
sizeBytes: totalSize,
|
|
1222
|
+
constraintsApplied: {
|
|
1223
|
+
maxSizeBytes: totalSize,
|
|
1224
|
+
// `maxDurationSeconds` deliberately omitted (not `null`): parity
|
|
1225
|
+
// comparator filters `undefined` keys from both sides; cross-SDK
|
|
1226
|
+
// upload_small precedent.
|
|
1227
|
+
processingClassPreAssignment: UploadConstraintsAppliedProcessingClassPreAssignmentEnum.unknown,
|
|
1228
|
+
},
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
// -----------------------------------------------------------------------
|
|
1232
|
+
// SDK-3 (Wb6ebOMM) — resume-support endpoints
|
|
1233
|
+
// -----------------------------------------------------------------------
|
|
1234
|
+
/**
|
|
1235
|
+
* Fetch the durable status of an in-progress multipart upload session.
|
|
1236
|
+
*
|
|
1237
|
+
* Walks every page of `GET /api/uploads/multipart/{uploadId}/status`
|
|
1238
|
+
* (paginated via `next_part_number_marker` + `is_truncated`) and returns
|
|
1239
|
+
* the aggregated state. Callers see the complete set of recorded parts
|
|
1240
|
+
* across pages without driving the cursor themselves.
|
|
1241
|
+
*
|
|
1242
|
+
* Anonymous-initiated sessions return 403 → `GislMultipartSessionAuthRequiredError`.
|
|
1243
|
+
* Non-existent / expired sessions return 404 → `GislMultipartSessionNotFoundError`.
|
|
1244
|
+
* Authed-but-non-owning callers return 403 → `GislMultipartSessionOwnershipError`.
|
|
1245
|
+
*
|
|
1246
|
+
* TODO(HxUmVr3Y): replace hand-coded response shape on regen.
|
|
1247
|
+
*/
|
|
1248
|
+
async getUploadStatus(uploadId, opts = {}) {
|
|
1249
|
+
if (typeof uploadId !== 'string' || uploadId === '') {
|
|
1250
|
+
throw new GislError('getUploadStatus: uploadId must be a non-empty string.');
|
|
1251
|
+
}
|
|
1252
|
+
return this.walkUploadStatus(uploadId, opts);
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Re-presign a batch of missing part numbers on an in-progress multipart
|
|
1256
|
+
* session.
|
|
1257
|
+
*
|
|
1258
|
+
* Validates client-side BEFORE the HTTP round-trip:
|
|
1259
|
+
* - `partNumbers` non-empty
|
|
1260
|
+
* - length <=100 (server raw-body cap is 8 KiB before json_decode)
|
|
1261
|
+
* - every entry an integer in `[2, totalParts]` — part 1 is sealed at
|
|
1262
|
+
* initiate (re-presigning it would break the etag recorded server-side
|
|
1263
|
+
* for /complete)
|
|
1264
|
+
* - entries unique
|
|
1265
|
+
* - `totalParts` <=10 000 (S3 hard limit; mirrors the SDK-1 ceiling guard)
|
|
1266
|
+
*
|
|
1267
|
+
* TODO(HxUmVr3Y): replace hand-coded request/response shapes on regen.
|
|
1268
|
+
*/
|
|
1269
|
+
async presignParts(uploadId, partNumbers, totalParts, opts = {}) {
|
|
1270
|
+
if (typeof uploadId !== 'string' || uploadId === '') {
|
|
1271
|
+
throw new GislError('presignParts: uploadId must be a non-empty string.');
|
|
1272
|
+
}
|
|
1273
|
+
if (typeof totalParts !== 'number' ||
|
|
1274
|
+
!Number.isInteger(totalParts) ||
|
|
1275
|
+
totalParts < 1) {
|
|
1276
|
+
throw new GislError(`presignParts: totalParts must be a positive integer, got ${String(totalParts)}.`);
|
|
1277
|
+
}
|
|
1278
|
+
if (totalParts > S3_MAX_MULTIPART_PARTS) {
|
|
1279
|
+
throw new GislMultipartPartCountError(`presignParts: totalParts=${totalParts} exceeds the S3 ${S3_MAX_MULTIPART_PARTS}-part ` +
|
|
1280
|
+
'multipart limit. Refusing to re-presign on a session that cannot complete.', totalParts, S3_MAX_MULTIPART_PARTS);
|
|
1281
|
+
}
|
|
1282
|
+
if (!Array.isArray(partNumbers) || partNumbers.length === 0) {
|
|
1283
|
+
throw new GislError('presignParts: partNumbers must be a non-empty array.');
|
|
1284
|
+
}
|
|
1285
|
+
if (partNumbers.length > 100) {
|
|
1286
|
+
throw new GislError(`presignParts: partNumbers has ${partNumbers.length} entries — server caps batches at 100.`);
|
|
1287
|
+
}
|
|
1288
|
+
const seen = new Set();
|
|
1289
|
+
for (const n of partNumbers) {
|
|
1290
|
+
if (typeof n !== 'number' ||
|
|
1291
|
+
!Number.isInteger(n) ||
|
|
1292
|
+
n < 2 ||
|
|
1293
|
+
n > totalParts) {
|
|
1294
|
+
throw new GislError(`presignParts: partNumbers entry ${String(n)} is not an integer in [2, ${totalParts}]. ` +
|
|
1295
|
+
'Part 1 is sealed at initiate; re-presigning it would invalidate the recorded etag for /complete.');
|
|
1296
|
+
}
|
|
1297
|
+
if (seen.has(n)) {
|
|
1298
|
+
throw new GislError(`presignParts: partNumbers contains duplicate ${n}.`);
|
|
1299
|
+
}
|
|
1300
|
+
seen.add(n);
|
|
1301
|
+
}
|
|
1302
|
+
const path = `/api/uploads/multipart/${encodeURIComponent(uploadId)}/presign`;
|
|
1303
|
+
return this.request('POST', path, {
|
|
1304
|
+
// Hand-coded snake_case wire body. TODO(HxUmVr3Y): replace with
|
|
1305
|
+
// generated `*RequestToJSON` helper on regen.
|
|
1306
|
+
body: { part_numbers: [...partNumbers] },
|
|
1307
|
+
deserialize: (raw) => {
|
|
1308
|
+
// Hand-coded snake_case -> camelCase. TODO(HxUmVr3Y): replace with
|
|
1309
|
+
// generated FromJSON helper on regen.
|
|
1310
|
+
const r = raw;
|
|
1311
|
+
if (typeof r.upload_id !== 'string' || !Array.isArray(r.presigned_urls)) {
|
|
1312
|
+
throw new GislError('presignParts: malformed response envelope.');
|
|
1313
|
+
}
|
|
1314
|
+
return {
|
|
1315
|
+
uploadId: r.upload_id,
|
|
1316
|
+
presignedUrls: r.presigned_urls.map((p) => ({
|
|
1317
|
+
partNumber: p.part_number,
|
|
1318
|
+
url: p.url,
|
|
1319
|
+
expiresAt: p.expires_at,
|
|
1320
|
+
})),
|
|
1321
|
+
};
|
|
1322
|
+
},
|
|
1323
|
+
signal: opts.signal,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Extend the manifest TTL of an in-progress multipart upload session.
|
|
1328
|
+
*
|
|
1329
|
+
* The durable session manifest defaults to a 48 h TTL (decoupled from the
|
|
1330
|
+
* shorter presigned-URL TTL). For a long-running resume that spans days
|
|
1331
|
+
* (e.g. an upload paused overnight on flaky Wi-Fi), callers SHOULD invoke
|
|
1332
|
+
* `keepaliveUpload` every **12-24 h** while resuming — the 12-24 h band
|
|
1333
|
+
* leaves >=24 h of slack against the 48 h ceiling even with worst-case
|
|
1334
|
+
* clock skew between client and server. The server atomically refreshes
|
|
1335
|
+
* the Redis EXPIRE for the manifest key; the call is idempotent.
|
|
1336
|
+
*
|
|
1337
|
+
* TODO(HxUmVr3Y): replace hand-coded response shape on regen.
|
|
1338
|
+
*/
|
|
1339
|
+
async keepaliveUpload(uploadId, opts = {}) {
|
|
1340
|
+
if (typeof uploadId !== 'string' || uploadId === '') {
|
|
1341
|
+
throw new GislError('keepaliveUpload: uploadId must be a non-empty string.');
|
|
1342
|
+
}
|
|
1343
|
+
const path = `/api/uploads/multipart/${encodeURIComponent(uploadId)}/keepalive`;
|
|
1344
|
+
return this.request('POST', path, {
|
|
1345
|
+
// Server expects an empty body; pass an empty object so the `request`
|
|
1346
|
+
// helper sets `Content-Type: application/json` for symmetry with the
|
|
1347
|
+
// other JSON-bodied POSTs. The endpoint ignores any fields if present.
|
|
1348
|
+
body: {},
|
|
1349
|
+
deserialize: (raw) => {
|
|
1350
|
+
// Hand-coded snake_case -> camelCase. TODO(HxUmVr3Y): replace with
|
|
1351
|
+
// generated FromJSON helper on regen.
|
|
1352
|
+
const r = raw;
|
|
1353
|
+
if (typeof r.upload_id !== 'string' ||
|
|
1354
|
+
typeof r.manifest_expires_at !== 'string') {
|
|
1355
|
+
throw new GislError('keepaliveUpload: malformed response envelope.');
|
|
1356
|
+
}
|
|
1357
|
+
return {
|
|
1358
|
+
uploadId: r.upload_id,
|
|
1359
|
+
manifestExpiresAt: r.manifest_expires_at,
|
|
1360
|
+
};
|
|
1361
|
+
},
|
|
1362
|
+
signal: opts.signal,
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Private walk-pagination helper for /status. Aggregates every page into
|
|
1367
|
+
* a single `_Sdk3HandCodedMultipartStatusResult`. AbortSignal short-circuits
|
|
1368
|
+
* the loop between page fetches AND propagates into each fetch.
|
|
1369
|
+
*
|
|
1370
|
+
* Limit pinned to 1000 (max per page) so we make the minimum number of
|
|
1371
|
+
* round-trips even for the worst-case ~10 pages on a 10 000-part upload.
|
|
1372
|
+
*/
|
|
1373
|
+
async walkUploadStatus(uploadId, opts) {
|
|
1374
|
+
const PAGE_LIMIT = 1000;
|
|
1375
|
+
// Slow-path DoS guard (code-reviewer minor 6). The cursor-advance check
|
|
1376
|
+
// already prevents an infinite loop; this cap additionally prevents a
|
|
1377
|
+
// pathological server that advances by 1 each page from forcing
|
|
1378
|
+
// O(totalParts) round-trips for a 10 000-part upload. PAGE_LIMIT=1000
|
|
1379
|
+
// means a healthy server completes in <=10 round-trips; 50 leaves
|
|
1380
|
+
// generous slack.
|
|
1381
|
+
const MAX_PAGES = 50;
|
|
1382
|
+
const collected = [];
|
|
1383
|
+
let cursor = 0;
|
|
1384
|
+
let totalParts = 0;
|
|
1385
|
+
let multipartUploadId = '';
|
|
1386
|
+
let cloudKey = '';
|
|
1387
|
+
let manifestExpiresAt = '';
|
|
1388
|
+
let recommendedChunkSize = 0;
|
|
1389
|
+
let pageCount = 0;
|
|
1390
|
+
while (true) {
|
|
1391
|
+
if (opts.signal?.aborted) {
|
|
1392
|
+
throw new GislAbortError('getUploadStatus aborted');
|
|
1393
|
+
}
|
|
1394
|
+
if (pageCount >= MAX_PAGES) {
|
|
1395
|
+
throw new GislError(`getUploadStatus: server returned more than ${MAX_PAGES} pages — refusing to ` +
|
|
1396
|
+
'continue. The /status endpoint should advance the cursor in 1000-part strides.');
|
|
1397
|
+
}
|
|
1398
|
+
pageCount += 1;
|
|
1399
|
+
const query = `?cursor=${cursor}&limit=${PAGE_LIMIT}`;
|
|
1400
|
+
// String-concat the query OUTSIDE the path backtick so the contract-drift
|
|
1401
|
+
// scanner (tests/unit/contract-drift.test.ts) sees the bare path
|
|
1402
|
+
// `/api/uploads/multipart/{id}/status`. Embedding `${query}` in the
|
|
1403
|
+
// template collapses to `/status{id}` and false-drifts — same reason
|
|
1404
|
+
// getSchema and getCreditsUsage concatenate their querystrings.
|
|
1405
|
+
const path = `/api/uploads/multipart/${encodeURIComponent(uploadId)}/status` + query;
|
|
1406
|
+
const page = await this.request('GET', path, { signal: opts.signal });
|
|
1407
|
+
// Defensive: server contract pins these fields. Strict-validate every
|
|
1408
|
+
// top-level field on each page (code-reviewer P7) so a malformed wire
|
|
1409
|
+
// envelope cannot silently coerce a missing key to '' / 0 / NaN and
|
|
1410
|
+
// flow it into MultipartCheckpointState.manifestExpiresAt or downstream
|
|
1411
|
+
// chunkSize guards.
|
|
1412
|
+
if (typeof page.total_parts !== 'number' || page.total_parts < 1) {
|
|
1413
|
+
throw new GislError('getUploadStatus: server page missing or invalid total_parts.');
|
|
1414
|
+
}
|
|
1415
|
+
if (typeof page.upload_id !== 'string' ||
|
|
1416
|
+
page.upload_id !== uploadId ||
|
|
1417
|
+
typeof page.multipart_upload_id !== 'string' ||
|
|
1418
|
+
page.multipart_upload_id === '' ||
|
|
1419
|
+
typeof page.cloud_key !== 'string' ||
|
|
1420
|
+
page.cloud_key === '' ||
|
|
1421
|
+
typeof page.manifest_expires_at !== 'string' ||
|
|
1422
|
+
page.manifest_expires_at === '' ||
|
|
1423
|
+
typeof page.recommended_chunk_size !== 'number') {
|
|
1424
|
+
throw new GislError('getUploadStatus: server page missing required fields or returned a ' +
|
|
1425
|
+
`mismatching upload_id (expected ${uploadId}, got ` +
|
|
1426
|
+
`${String(page.upload_id)}).`);
|
|
1427
|
+
}
|
|
1428
|
+
totalParts = page.total_parts;
|
|
1429
|
+
multipartUploadId = page.multipart_upload_id;
|
|
1430
|
+
cloudKey = page.cloud_key;
|
|
1431
|
+
manifestExpiresAt = page.manifest_expires_at;
|
|
1432
|
+
recommendedChunkSize = page.recommended_chunk_size;
|
|
1433
|
+
for (const p of page.uploaded_parts ?? []) {
|
|
1434
|
+
collected.push({
|
|
1435
|
+
partNumber: p.part_number,
|
|
1436
|
+
etag: p.etag,
|
|
1437
|
+
sizeBytes: p.size_bytes,
|
|
1438
|
+
lastModified: p.last_modified,
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
if (!page.is_truncated)
|
|
1442
|
+
break;
|
|
1443
|
+
// Advance cursor; guard against a contract-violating non-advancing
|
|
1444
|
+
// marker that would loop forever.
|
|
1445
|
+
if (typeof page.next_part_number_marker !== 'number' ||
|
|
1446
|
+
page.next_part_number_marker <= cursor) {
|
|
1447
|
+
throw new GislError('getUploadStatus: server is_truncated=true but next_part_number_marker ' +
|
|
1448
|
+
`did not advance (was ${cursor}, got ${String(page.next_part_number_marker)}).`);
|
|
1449
|
+
}
|
|
1450
|
+
cursor = page.next_part_number_marker;
|
|
1451
|
+
}
|
|
1452
|
+
// Sort ascending by partNumber — server SHOULD already deliver in order
|
|
1453
|
+
// page-by-page, but a defensive sort keeps the aggregated shape's
|
|
1454
|
+
// contract simple to consume (resume-branch missing-parts compute scans
|
|
1455
|
+
// it linearly).
|
|
1456
|
+
collected.sort((a, b) => a.partNumber - b.partNumber);
|
|
1457
|
+
return {
|
|
1458
|
+
uploadId,
|
|
1459
|
+
multipartUploadId,
|
|
1460
|
+
cloudKey,
|
|
1461
|
+
totalParts,
|
|
1462
|
+
uploadedParts: collected,
|
|
1463
|
+
manifestExpiresAt,
|
|
1464
|
+
recommendedChunkSize,
|
|
240
1465
|
};
|
|
241
1466
|
}
|
|
242
1467
|
// -----------------------------------------------------------------------
|
|
@@ -278,6 +1503,49 @@ export class GislClient {
|
|
|
278
1503
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
279
1504
|
}
|
|
280
1505
|
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Cancel a workflow. Idempotent — cancelling an already-cancelled
|
|
1508
|
+
* workflow returns 200 with the same shape (and the original
|
|
1509
|
+
* `cancelledAt`). Cancelling a `completed` / `failed` /
|
|
1510
|
+
* `partially_failed` / `expired` workflow returns 409.
|
|
1511
|
+
*
|
|
1512
|
+
* The response's `billingEffect` field tells the caller what
|
|
1513
|
+
* happened to outstanding reservations:
|
|
1514
|
+
* - `unspent_reservation_released` — workflow was active or paused
|
|
1515
|
+
* and the unspent portion of the reservation has been refunded.
|
|
1516
|
+
* The refund appears as a separate `CreditTransaction` with
|
|
1517
|
+
* `type: refund`.
|
|
1518
|
+
* - `none` — no refund (all reserved credits were already consumed
|
|
1519
|
+
* by completed jobs, or this is an idempotent re-cancel).
|
|
1520
|
+
*
|
|
1521
|
+
* In-flight operations may continue running briefly after the
|
|
1522
|
+
* cancel response while their Lambda processes terminate; the
|
|
1523
|
+
* response is the binding "no further reservations will be made"
|
|
1524
|
+
* signal.
|
|
1525
|
+
*/
|
|
1526
|
+
async cancelWorkflow(workflowId) {
|
|
1527
|
+
return this.request('POST', `/api/workflows/${encodeURIComponent(workflowId)}/cancel`, {
|
|
1528
|
+
deserialize: WorkflowCancelResponseFromJSON,
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Resume a workflow that is in `paused_insufficient_credits`.
|
|
1533
|
+
*
|
|
1534
|
+
* Resume succeeds only when `availableCredits` covers the next
|
|
1535
|
+
* reservation. If the balance is still insufficient, throws
|
|
1536
|
+
* `GislBalanceExhaustedError` (402, same envelope as the workflow-
|
|
1537
|
+
* create 402 path) and the workflow stays paused. If the workflow
|
|
1538
|
+
* is past its `expiresAt` (default 7-day TTL from `pausedAt`),
|
|
1539
|
+
* throws `GislWorkflowExpiredError` (422) and the workflow has
|
|
1540
|
+
* transitioned to `expired` — callers cannot un-expire a workflow.
|
|
1541
|
+
* Resuming a workflow that is not in `paused_insufficient_credits`
|
|
1542
|
+
* is a 409 (no-op).
|
|
1543
|
+
*/
|
|
1544
|
+
async resumeWorkflow(workflowId) {
|
|
1545
|
+
return this.request('POST', `/api/workflows/${encodeURIComponent(workflowId)}/resume`, {
|
|
1546
|
+
deserialize: WorkflowResumeResponseFromJSON,
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
281
1549
|
/**
|
|
282
1550
|
* Get download URLs for a completed workflow.
|
|
283
1551
|
*/
|
|
@@ -289,13 +1557,105 @@ export class GislClient {
|
|
|
289
1557
|
/**
|
|
290
1558
|
* Stream SSE events for a workflow. Returns an async iterable.
|
|
291
1559
|
*/
|
|
292
|
-
async streamEvents(workflowId) {
|
|
1560
|
+
async streamEvents(workflowId, opts = {}) {
|
|
293
1561
|
const eventsPath = `/api/workflows/${encodeURIComponent(workflowId)}/events`;
|
|
294
|
-
|
|
1562
|
+
// SSE-lifetime AbortController. `request()` builds its own controller
|
|
1563
|
+
// and tears it down (`clearTimeout(timer); unbind()`) in its `finally`
|
|
1564
|
+
// the instant the response headers arrive — BEFORE the SSE body
|
|
1565
|
+
// streams — so that controller cannot cancel a long-lived stream.
|
|
1566
|
+
// `streamEvents` must own a controller for the stream's whole lifetime.
|
|
1567
|
+
// We pass its signal to `request()` too, so a pre-aborted signal /
|
|
1568
|
+
// connect-phase abort still fast-fails. After headers, the live socket
|
|
1569
|
+
// is freed only by `reader.cancel()` inside `parseSseStream` — driven
|
|
1570
|
+
// by aborting this controller from the iterator wrapper's
|
|
1571
|
+
// `return()`/`throw()` (a generator's own `return()` is unreachable
|
|
1572
|
+
// while suspended at `await reader.read()`; canonical pattern:
|
|
1573
|
+
// openai-node `Stream[Symbol.asyncIterator]` + PR #1314).
|
|
1574
|
+
const controller = new AbortController();
|
|
1575
|
+
// Compose an optional consumer-supplied signal onto our controller.
|
|
1576
|
+
// The teardown MUST run (normal completion, error, OR early return)
|
|
1577
|
+
// or a long-lived consumer AbortController leaks listeners.
|
|
1578
|
+
const releaseConsumerSignal = bindAbortSignal(opts.signal, controller);
|
|
1579
|
+
let response;
|
|
1580
|
+
try {
|
|
1581
|
+
response = await this.request('GET', eventsPath, {
|
|
1582
|
+
rawResponse: true,
|
|
1583
|
+
signal: controller.signal,
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
catch (err) {
|
|
1587
|
+
releaseConsumerSignal();
|
|
1588
|
+
throw err;
|
|
1589
|
+
}
|
|
295
1590
|
if (!response.ok) {
|
|
296
|
-
|
|
1591
|
+
try {
|
|
1592
|
+
await this.handleResponse(response, eventsPath); // always throws
|
|
1593
|
+
}
|
|
1594
|
+
finally {
|
|
1595
|
+
releaseConsumerSignal();
|
|
1596
|
+
}
|
|
297
1597
|
}
|
|
298
|
-
|
|
1598
|
+
const inner = parseSseStream(response, { signal: controller.signal });
|
|
1599
|
+
let started = false;
|
|
1600
|
+
let settled = false;
|
|
1601
|
+
// Idempotent teardown. `abort` only on consumer-driven early
|
|
1602
|
+
// termination (return/throw) — NOT on normal completion or stream
|
|
1603
|
+
// error, where aborting would be a spurious "aborted though it
|
|
1604
|
+
// wasn't" signal (openai-node#194). If the consumer disposes the
|
|
1605
|
+
// iterator before ever pulling an event, the inner generator never
|
|
1606
|
+
// ran, so its `finally` won't cancel the body — cancel it here as a
|
|
1607
|
+
// backstop (the body is still unlocked: no reader was acquired).
|
|
1608
|
+
const cleanup = (abort) => {
|
|
1609
|
+
if (settled)
|
|
1610
|
+
return;
|
|
1611
|
+
settled = true;
|
|
1612
|
+
if (abort)
|
|
1613
|
+
controller.abort();
|
|
1614
|
+
if (!started)
|
|
1615
|
+
void response.body?.cancel().catch(() => { });
|
|
1616
|
+
releaseConsumerSignal();
|
|
1617
|
+
};
|
|
1618
|
+
// Abort-before-first-pull backstop. If the consumer aborts (their
|
|
1619
|
+
// signal, composed onto `controller`) and then drops the iterator
|
|
1620
|
+
// WITHOUT ever calling next()/return()/throw(), nothing else frees the
|
|
1621
|
+
// already-fetched body: `request()` unbound its fetch controller at
|
|
1622
|
+
// header receipt, and `parseSseStream` only attaches its reader +
|
|
1623
|
+
// abort listener once iteration starts. `cleanup`'s `!started` branch
|
|
1624
|
+
// only runs from the wrapper methods, so it never fires on a pure
|
|
1625
|
+
// abort-and-drop. Cancel the (still-unlocked) body directly here.
|
|
1626
|
+
// Once started, `parseSseStream` owns the locked reader and cancels
|
|
1627
|
+
// via its own abort listener, so this no-ops.
|
|
1628
|
+
controller.signal.addEventListener('abort', () => {
|
|
1629
|
+
if (!started)
|
|
1630
|
+
void response.body?.cancel().catch(() => { });
|
|
1631
|
+
}, { once: true });
|
|
1632
|
+
const wrapper = {
|
|
1633
|
+
async next(...args) {
|
|
1634
|
+
started = true;
|
|
1635
|
+
try {
|
|
1636
|
+
const result = await inner.next(...args);
|
|
1637
|
+
if (result.done)
|
|
1638
|
+
cleanup(false);
|
|
1639
|
+
return result;
|
|
1640
|
+
}
|
|
1641
|
+
catch (err) {
|
|
1642
|
+
cleanup(false);
|
|
1643
|
+
throw err;
|
|
1644
|
+
}
|
|
1645
|
+
},
|
|
1646
|
+
async return(value) {
|
|
1647
|
+
cleanup(true);
|
|
1648
|
+
return inner.return(value);
|
|
1649
|
+
},
|
|
1650
|
+
async throw(err) {
|
|
1651
|
+
cleanup(true);
|
|
1652
|
+
return inner.throw(err);
|
|
1653
|
+
},
|
|
1654
|
+
[Symbol.asyncIterator]() {
|
|
1655
|
+
return this;
|
|
1656
|
+
},
|
|
1657
|
+
};
|
|
1658
|
+
return wrapper;
|
|
299
1659
|
}
|
|
300
1660
|
// -----------------------------------------------------------------------
|
|
301
1661
|
// File metadata
|
|
@@ -313,12 +1673,58 @@ export class GislClient {
|
|
|
313
1673
|
// -----------------------------------------------------------------------
|
|
314
1674
|
/**
|
|
315
1675
|
* Get the operations schema (available types, options, constraints).
|
|
316
|
-
*
|
|
1676
|
+
*
|
|
1677
|
+
* Returns raw JSON (no envelope). The response is **per-tier private**
|
|
1678
|
+
* (cache key includes the caller's `user_tier`); CDN-style public
|
|
1679
|
+
* caching is not used. Pass `ifNoneMatch` / `ifModifiedSince` from a
|
|
1680
|
+
* previous response to revalidate — a 304 surfaces as
|
|
1681
|
+
* `{ notModified: true, etag, lastModified }` so callers can keep
|
|
1682
|
+
* using their cached copy.
|
|
317
1683
|
*/
|
|
318
|
-
async getSchema() {
|
|
319
|
-
|
|
320
|
-
|
|
1684
|
+
async getSchema(options = {}) {
|
|
1685
|
+
const params = new URLSearchParams();
|
|
1686
|
+
if (options.mimeType !== undefined)
|
|
1687
|
+
params.set('mime_type', options.mimeType);
|
|
1688
|
+
if (options.operation !== undefined)
|
|
1689
|
+
params.set('operation', options.operation);
|
|
1690
|
+
const query = params.toString();
|
|
1691
|
+
// The contract-drift test (tests/unit/contract-drift.test.ts) scans this
|
|
1692
|
+
// file for path literals via a regex that picks up both single-quoted
|
|
1693
|
+
// strings AND backtick templates. Embedding the querystring in a single
|
|
1694
|
+
// template would normalise to a path-with-querystring that no contract
|
|
1695
|
+
// path matches. Compose with concatenation so only the bare path appears
|
|
1696
|
+
// as a literal.
|
|
1697
|
+
const path = '/api/operations/schema' + (query ? '?' + query : '');
|
|
1698
|
+
const headers = {};
|
|
1699
|
+
if (options.ifNoneMatch !== undefined)
|
|
1700
|
+
headers['If-None-Match'] = options.ifNoneMatch;
|
|
1701
|
+
if (options.ifModifiedSince !== undefined)
|
|
1702
|
+
headers['If-Modified-Since'] = options.ifModifiedSince;
|
|
1703
|
+
const response = await this.request('GET', path, {
|
|
1704
|
+
rawResponse: true,
|
|
1705
|
+
signal: options.signal,
|
|
1706
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
321
1707
|
});
|
|
1708
|
+
const etag = response.headers.get('etag') ?? undefined;
|
|
1709
|
+
const lastModified = response.headers.get('last-modified') ?? undefined;
|
|
1710
|
+
if (response.status === 304) {
|
|
1711
|
+
return { notModified: true, etag, lastModified };
|
|
1712
|
+
}
|
|
1713
|
+
if (!response.ok) {
|
|
1714
|
+
let errorMessage = 'Unknown error';
|
|
1715
|
+
try {
|
|
1716
|
+
const errJson = (await response.json());
|
|
1717
|
+
if (errJson.error)
|
|
1718
|
+
errorMessage = errJson.error;
|
|
1719
|
+
}
|
|
1720
|
+
catch {
|
|
1721
|
+
// Non-JSON body — keep generic message.
|
|
1722
|
+
}
|
|
1723
|
+
throw new GislApiError(response.status, errorMessage, path);
|
|
1724
|
+
}
|
|
1725
|
+
const raw = await response.json();
|
|
1726
|
+
const data = OperationsSchemaResponseFromJSON(raw);
|
|
1727
|
+
return { notModified: false, data, etag, lastModified };
|
|
322
1728
|
}
|
|
323
1729
|
/**
|
|
324
1730
|
* Retry a failed operation.
|
|
@@ -328,4 +1734,213 @@ export class GislClient {
|
|
|
328
1734
|
deserialize: RetryResponseFromJSON,
|
|
329
1735
|
});
|
|
330
1736
|
}
|
|
1737
|
+
// -----------------------------------------------------------------------
|
|
1738
|
+
// Contact
|
|
1739
|
+
// -----------------------------------------------------------------------
|
|
1740
|
+
/**
|
|
1741
|
+
* Submit a contact-form message. The endpoint returns 204 No Content on
|
|
1742
|
+
* success, so this method resolves to `void`.
|
|
1743
|
+
*
|
|
1744
|
+
* Validation errors (e.g. missing `email`, non-empty honeypot `website`)
|
|
1745
|
+
* surface as `GislValidationError` from the standard error envelope.
|
|
1746
|
+
*/
|
|
1747
|
+
async submitContact(payload) {
|
|
1748
|
+
await this.request('POST', '/api/contact', {
|
|
1749
|
+
body: payload,
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
// -----------------------------------------------------------------------
|
|
1753
|
+
// Credits / billing
|
|
1754
|
+
// -----------------------------------------------------------------------
|
|
1755
|
+
/**
|
|
1756
|
+
* Get a snapshot of the caller's current credit position. The canonical
|
|
1757
|
+
* billing-state surface — `BalanceExhaustedResponse` (402) on workflow
|
|
1758
|
+
* creation includes pre-error counters for context, but UIs should drive
|
|
1759
|
+
* spend-now affordances and tier-upgrade prompts off this endpoint, not
|
|
1760
|
+
* off the error envelope.
|
|
1761
|
+
*/
|
|
1762
|
+
async getCreditsBalance() {
|
|
1763
|
+
return this.request('GET', '/api/v2/credits/balance', {
|
|
1764
|
+
deserialize: CreditsBalanceResponseFromJSON,
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
// -----------------------------------------------------------------------
|
|
1768
|
+
// Auth
|
|
1769
|
+
// -----------------------------------------------------------------------
|
|
1770
|
+
/**
|
|
1771
|
+
* Authenticate with email/password. On success the server issues a
|
|
1772
|
+
* session cookie via `Set-Cookie`; subsequent requests authenticate
|
|
1773
|
+
* via that cookie when the client is configured with
|
|
1774
|
+
* `useSessionCookie: true`.
|
|
1775
|
+
*
|
|
1776
|
+
* Failure modes per ticket FX6mbTJD:
|
|
1777
|
+
* - **401** `invalid_credentials` (collapsed with unverified
|
|
1778
|
+
* accounts for anti-enumeration) → `GislAuthError`.
|
|
1779
|
+
* - **403** account-state failures (`account_locked`,
|
|
1780
|
+
* `account_disabled`, `account_deleted`,
|
|
1781
|
+
* `account_deletion_expired`) → `GislAuthError`.
|
|
1782
|
+
* - **429** infrastructure rate-limit → `GislApiError` with
|
|
1783
|
+
* the `Retry-After` header echoed on the response.
|
|
1784
|
+
*
|
|
1785
|
+
* Node session persistence (cookie-jar across processes) is out of
|
|
1786
|
+
* scope — this method only touches the request side.
|
|
1787
|
+
*/
|
|
1788
|
+
async login(credentials) {
|
|
1789
|
+
return this.request('POST', '/api/auth/login', {
|
|
1790
|
+
body: credentials,
|
|
1791
|
+
deserialize: LoginUser200ResponseDataFromJSON,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Invalidate the current session.
|
|
1796
|
+
*
|
|
1797
|
+
* Idempotent: calling logout without an active session returns 401,
|
|
1798
|
+
* but the SDK collapses both 200 and 401 into a single "logged out"
|
|
1799
|
+
* outcome — `logout()` resolves to `void` in either case so caller
|
|
1800
|
+
* cleanup code does not need to special-case the not-currently-
|
|
1801
|
+
* authenticated path. Other errors (e.g. 500, network failures)
|
|
1802
|
+
* still throw.
|
|
1803
|
+
*/
|
|
1804
|
+
async logout() {
|
|
1805
|
+
try {
|
|
1806
|
+
await this.request('POST', '/api/auth/logout', {});
|
|
1807
|
+
}
|
|
1808
|
+
catch (err) {
|
|
1809
|
+
// Treat 401 as success (already logged out — idempotent per
|
|
1810
|
+
// contract). Logout 401 is a bare ErrorEnvelope with no
|
|
1811
|
+
// `error_type`, so it surfaces as the base GislApiError rather
|
|
1812
|
+
// than the typed GislAuthError — match on the status code to
|
|
1813
|
+
// capture both shapes.
|
|
1814
|
+
if (err instanceof GislApiError && err.statusCode === 401) {
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
throw err;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
// -----------------------------------------------------------------------
|
|
1821
|
+
// External imports
|
|
1822
|
+
// -----------------------------------------------------------------------
|
|
1823
|
+
/**
|
|
1824
|
+
* Register a one-shot bearer URL (S3 presigned, GCS signed, Azure
|
|
1825
|
+
* SAS, Dropbox shared link, public HTTPS) and receive an opaque
|
|
1826
|
+
* `externalSourceId` handle. Subsequent workflows reference the
|
|
1827
|
+
* handle via `WorkflowSource` of `type: external_import` —
|
|
1828
|
+
* compose with the [`externalImportSource()`](./types.ts) factory.
|
|
1829
|
+
*
|
|
1830
|
+
* Per ADR-0005 §"SSRF posture": the server validates 8 rules at
|
|
1831
|
+
* registration time AND again at fetch time. HTTPS-only;
|
|
1832
|
+
* private/loopback/cloud-metadata IPs are rejected (403). The
|
|
1833
|
+
* original URL + password are encrypted at rest and never
|
|
1834
|
+
* returned in any response.
|
|
1835
|
+
*
|
|
1836
|
+
* Currently `availability: planned` — the runtime endpoint returns
|
|
1837
|
+
* 422 `feature_not_available` (or 404, per the cross-repo rollout)
|
|
1838
|
+
* until the external-import infrastructure ships. The method
|
|
1839
|
+
* exists today so consumers can write the integration ahead of
|
|
1840
|
+
* time.
|
|
1841
|
+
*/
|
|
1842
|
+
async createExternalImport(payload) {
|
|
1843
|
+
return this.request('POST', '/api/external-imports', {
|
|
1844
|
+
body: ExternalImportRequestToJSON(payload),
|
|
1845
|
+
deserialize: ExternalImportCreatedResponseFromJSON,
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
// -----------------------------------------------------------------------
|
|
1849
|
+
// Audio watermark
|
|
1850
|
+
// -----------------------------------------------------------------------
|
|
1851
|
+
/**
|
|
1852
|
+
* Decode a previously-embedded steganographic audio watermark
|
|
1853
|
+
* (per ticket I20). Pairs with the `audio_watermark` operation —
|
|
1854
|
+
* the operation embeds; this endpoint decodes.
|
|
1855
|
+
*
|
|
1856
|
+
* **Enterprise tier only.** Free / pro callers receive
|
|
1857
|
+
* `GislFeatureTierRestrictedError` (403).
|
|
1858
|
+
*
|
|
1859
|
+
* **Own watermarks only.** The decoder will refuse to extract from
|
|
1860
|
+
* media the caller did not mark themselves — mismatches return 404
|
|
1861
|
+
* (rather than leaking that *some* watermark was detected).
|
|
1862
|
+
*
|
|
1863
|
+
* Currently `availability: planned` — calls return
|
|
1864
|
+
* `GislFeatureNotAvailableError` (422) until the cross-repo Lambda
|
|
1865
|
+
* support ships. Decode requests are rate-limited independently
|
|
1866
|
+
* from workflow-create.
|
|
1867
|
+
*/
|
|
1868
|
+
async decodeAudioWatermark(payload) {
|
|
1869
|
+
// The generated request type is camelCase; convert to snake_case wire
|
|
1870
|
+
// shape before sending. Mirrors the multipart complete pattern.
|
|
1871
|
+
return this.request('POST', '/api/audio-watermark/decode', {
|
|
1872
|
+
body: AudioWatermarkDecodeRequestToJSON(payload),
|
|
1873
|
+
deserialize: AudioWatermarkDecodeResponseFromJSON,
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
// -----------------------------------------------------------------------
|
|
1877
|
+
// Upload probe / preflight
|
|
1878
|
+
// -----------------------------------------------------------------------
|
|
1879
|
+
/**
|
|
1880
|
+
* Probe an uploaded file for workflow-readiness — detects corruption,
|
|
1881
|
+
* unsupported codecs, and pre-assigns the processing class the server
|
|
1882
|
+
* would route the file to. Designed for the long-form merge edge case
|
|
1883
|
+
* where a single bad input would fail the whole workflow.
|
|
1884
|
+
*
|
|
1885
|
+
* Currently `availability: planned` — calls return
|
|
1886
|
+
* `GislFeatureNotAvailableError` (422) until the cross-repo Lambda
|
|
1887
|
+
* support ships. Idempotent: probing the same `fileId` twice returns
|
|
1888
|
+
* the cached result.
|
|
1889
|
+
*/
|
|
1890
|
+
async probeUpload(fileId) {
|
|
1891
|
+
return this.request('POST', `/api/uploads/${encodeURIComponent(fileId)}/probe`, {
|
|
1892
|
+
deserialize: UploadProbeResponseFromJSON,
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Probe N uploaded files in parallel and partition the results by
|
|
1897
|
+
* outcome. Returns `{ ok, rejected, errors }` so the caller can
|
|
1898
|
+
* cleanly drop bad clips before submitting a long-form merge
|
|
1899
|
+
* workflow. Probe-call failures (including the
|
|
1900
|
+
* `feature_not_available` 422 returned while the endpoint is
|
|
1901
|
+
* `availability: planned`) land in `errors` rather than throwing,
|
|
1902
|
+
* so a partially-successful batch still yields useful aggregation.
|
|
1903
|
+
*/
|
|
1904
|
+
async preflightClips(fileIds) {
|
|
1905
|
+
const settled = await Promise.allSettled(fileIds.map((fileId) => this.probeUpload(fileId)));
|
|
1906
|
+
const ok = [];
|
|
1907
|
+
const rejected = [];
|
|
1908
|
+
const errors = [];
|
|
1909
|
+
for (let i = 0; i < settled.length; i++) {
|
|
1910
|
+
const result = settled[i];
|
|
1911
|
+
const fileId = fileIds[i];
|
|
1912
|
+
if (result.status === 'fulfilled') {
|
|
1913
|
+
if (result.value.probeStatus === 'ok') {
|
|
1914
|
+
ok.push(result.value);
|
|
1915
|
+
}
|
|
1916
|
+
else {
|
|
1917
|
+
rejected.push(result.value);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
else {
|
|
1921
|
+
errors.push({ fileId, error: result.reason });
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return { ok, rejected, errors };
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Get a paginated page of credit transaction history for the caller.
|
|
1928
|
+
* Server defaults: `limit=20`, `offset=0`. Most-recent-first.
|
|
1929
|
+
*/
|
|
1930
|
+
async getCreditsUsage(options = {}) {
|
|
1931
|
+
const params = new URLSearchParams();
|
|
1932
|
+
if (options.limit !== undefined)
|
|
1933
|
+
params.set('limit', String(options.limit));
|
|
1934
|
+
if (options.offset !== undefined)
|
|
1935
|
+
params.set('offset', String(options.offset));
|
|
1936
|
+
const query = params.toString();
|
|
1937
|
+
// String concatenation (not template) so the contract-drift path scanner
|
|
1938
|
+
// picks up the literal path. See getSchema for the same pattern.
|
|
1939
|
+
const path = query.length > 0
|
|
1940
|
+
? '/api/v2/credits/usage' + '?' + query
|
|
1941
|
+
: '/api/v2/credits/usage';
|
|
1942
|
+
return this.request('GET', path, {
|
|
1943
|
+
deserialize: CreditsUsageResponseFromJSON,
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
331
1946
|
}
|