@giveitsmaller/sdk 0.9.0 → 0.10.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 +8 -0
- package/dist/_audit.js +0 -1
- package/dist/builder.js +65 -13
- package/dist/client.d.ts +38 -2
- package/dist/client.js +131 -7
- package/dist/credentials.js +4 -2
- package/dist/ergonomic/preset_resolver.js +4 -5
- package/dist/ergonomic/presets/image_compress.d.ts +1 -9
- package/dist/ergonomic/presets/image_compress.js +6 -25
- package/dist/ergonomic/presets/index.d.ts +1 -1
- package/dist/ergonomic/presets/index.js +1 -1
- package/dist/errors.d.ts +59 -1
- package/dist/errors.js +51 -0
- package/dist/file-first.d.ts +233 -0
- package/dist/file-first.js +585 -21
- package/dist/generated/sdk_spec/enums.d.ts +0 -11
- package/dist/generated/sdk_spec/enums.js +0 -7
- package/dist/generated/sdk_spec/errors.d.ts +1 -1
- package/dist/generated/sdk_spec/errors.js +26 -0
- package/dist/generated/sdk_spec/presets.js +0 -3
- package/dist/generated/sdk_spec/version.d.ts +2 -2
- package/dist/generated/sdk_spec/version.js +2 -2
- package/dist/handle.js +34 -14
- package/dist/index.browser.d.ts +1 -0
- package/dist/index.browser.js +14 -0
- package/dist/index.core.d.ts +35 -0
- package/dist/index.core.js +102 -0
- package/dist/index.d.ts +1 -32
- package/dist/index.js +9 -89
- package/dist/lazy-downloader.d.ts +19 -0
- package/dist/lazy-downloader.js +19 -0
- package/dist/merge.d.ts +11 -0
- package/dist/merge.js +144 -45
- package/dist/node-fs.browser.d.ts +17 -0
- package/dist/node-fs.browser.js +7 -0
- package/dist/node-fs.d.ts +14 -0
- package/dist/node-fs.js +14 -0
- package/dist/sha256.d.ts +20 -0
- package/dist/sha256.js +108 -0
- package/dist/types.d.ts +54 -2
- package/dist/types.js +2 -0
- package/package.json +15 -2
package/dist/file-first.js
CHANGED
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Mirrors `packages/php/src/FileFirst/*`.
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import { GislConfigError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
|
|
13
13
|
import { _detectCompressMedia, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, } from './builder.js';
|
|
14
|
-
import {
|
|
14
|
+
import { LazyHttpDownloader } from './lazy-downloader.js';
|
|
15
15
|
import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
|
|
16
16
|
import { OptimizeFor } from './generated/sdk_spec/enums.js';
|
|
17
|
-
import { uploadSource } from './types.js';
|
|
17
|
+
import { uploadSource, jobOutputSource } from './types.js';
|
|
18
|
+
// Value import used only at call-time (inside MergedRecipe.toWorkflowPayload),
|
|
19
|
+
// never at module-eval, so the file-first <-> merge <-> handle import cycle
|
|
20
|
+
// resolves cleanly under ESM (same deferred-usage discipline as `Handle`).
|
|
21
|
+
import { wireMergeOptions } from './merge.js';
|
|
18
22
|
// Deferred-usage-only import: `Handle` is constructed inside `submit()` at call
|
|
19
23
|
// time, never at module-eval, so the handle.ts <-> file-first.ts back-edge
|
|
20
24
|
// (handle.ts imports RunResult/projectDownloadsToRunResult from here) resolves
|
|
@@ -299,6 +303,59 @@ export function isFanoutStatus(finalStatus) {
|
|
|
299
303
|
const jobs = finalStatus.jobs ?? [];
|
|
300
304
|
return jobs.length > 0 && jobs.every((job) => _FANOUT_REF.test(job.ref));
|
|
301
305
|
}
|
|
306
|
+
const _MERGE_SRC_REF = /^src_\d+$/;
|
|
307
|
+
/**
|
|
308
|
+
* True when a terminal status describes a fluent `files([...]).merge(...)`
|
|
309
|
+
* combine — at least one job ref `merge` and every OTHER job ref is `src_{i}`
|
|
310
|
+
* (the ids the {@link MergedRecipe} lowering assigns). The data-driven seam that
|
|
311
|
+
* lets {@link Handle.wait}/{@link Handle.result} project ONLY the merged output
|
|
312
|
+
* — filtering the `src_*` passthrough plumbing — even after a
|
|
313
|
+
* `client.workflow(id)` reattach (no construction-time marker), matching
|
|
314
|
+
* {@link MergedRecipe.run}'s `ref === 'merge'` filter. Mutually exclusive with
|
|
315
|
+
* {@link isFanoutStatus} (a fan-out's refs are all `file-{i}`).
|
|
316
|
+
*
|
|
317
|
+
* @internal Exported for the file-first `Handle`; not part of the public API.
|
|
318
|
+
*/
|
|
319
|
+
export function isMergeStatus(finalStatus) {
|
|
320
|
+
const jobs = finalStatus.jobs ?? [];
|
|
321
|
+
if (jobs.length === 0)
|
|
322
|
+
return false;
|
|
323
|
+
let hasMerge = false;
|
|
324
|
+
for (const job of jobs) {
|
|
325
|
+
if (job.ref === 'merge') {
|
|
326
|
+
hasMerge = true;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (!_MERGE_SRC_REF.test(job.ref))
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
return hasMerge;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* True when a terminal status describes a fluent `files([...]).archive(...)`
|
|
336
|
+
* bundle — at least one job ref `archive` and every OTHER job ref is `src_{i}`
|
|
337
|
+
* (the ids the {@link ArchivedRecipe} lowering assigns). Lets
|
|
338
|
+
* {@link Handle.wait}/{@link Handle.result} project ONLY the archive output —
|
|
339
|
+
* filtering the `src_*` passthrough plumbing — even after a `client.workflow(id)`
|
|
340
|
+
* reattach. Mutually exclusive with {@link isFanoutStatus} / {@link isMergeStatus}.
|
|
341
|
+
*
|
|
342
|
+
* @internal Exported for the file-first `Handle`; not part of the public API.
|
|
343
|
+
*/
|
|
344
|
+
export function isArchiveStatus(finalStatus) {
|
|
345
|
+
const jobs = finalStatus.jobs ?? [];
|
|
346
|
+
if (jobs.length === 0)
|
|
347
|
+
return false;
|
|
348
|
+
let hasArchive = false;
|
|
349
|
+
for (const job of jobs) {
|
|
350
|
+
if (job.ref === 'archive') {
|
|
351
|
+
hasArchive = true;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (!_MERGE_SRC_REF.test(job.ref))
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
return hasArchive;
|
|
358
|
+
}
|
|
302
359
|
/** Named constructors for {@link FileInput} — mirror the PHP static factories. */
|
|
303
360
|
export const fileInput = {
|
|
304
361
|
path(path) {
|
|
@@ -307,6 +364,19 @@ export const fileInput = {
|
|
|
307
364
|
blob(blob) {
|
|
308
365
|
return { kind: 'blob', blob };
|
|
309
366
|
},
|
|
367
|
+
/**
|
|
368
|
+
* Reference an already-uploaded file by its upload id, instead of
|
|
369
|
+
* re-uploading bytes.
|
|
370
|
+
*
|
|
371
|
+
* Auth-ownership: an upload created by an **authenticated** caller is owned
|
|
372
|
+
* by that caller. If you reuse the id from a client configured with a
|
|
373
|
+
* *different* auth context (a different `apiKey` / session), workflow-create
|
|
374
|
+
* returns `404 upload_not_found` — the server enforces ownership (api
|
|
375
|
+
* PqpD9ySv). Reference an upload id only under the SAME auth that created it.
|
|
376
|
+
* The normal upload-then-create-in-one-client flow is consistent by
|
|
377
|
+
* construction (the same `Authorization` rides every request). Ownerless
|
|
378
|
+
* (anonymous-intake) uploads are unaffected.
|
|
379
|
+
*/
|
|
310
380
|
uploadId(fileId) {
|
|
311
381
|
return { kind: 'uploadId', fileId };
|
|
312
382
|
},
|
|
@@ -452,18 +522,16 @@ export class Recipe {
|
|
|
452
522
|
});
|
|
453
523
|
}
|
|
454
524
|
catch (err) {
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
if (err instanceof DOMException && err.name === 'AbortError')
|
|
464
|
-
throw err;
|
|
465
|
-
if (err instanceof GislApiError)
|
|
525
|
+
// TDqmkWpX: poll-fallback ONLY on a clean SSE stream-end
|
|
526
|
+
// (SseEndedWithoutTerminal) or a typed transport error (GislNetworkError).
|
|
527
|
+
// Everything else — caller-deadline, abort, an API error from /events, an
|
|
528
|
+
// onProgress callback throw (propagates as-is, NOT wrapped), anything
|
|
529
|
+
// unexpected — MUST propagate; re-issuing the same doomed request via poll
|
|
530
|
+
// would mask it. Mirrors the PHP BuilderInternals::awaitTerminal sealed-
|
|
531
|
+
// marker discipline.
|
|
532
|
+
if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
|
|
466
533
|
throw err;
|
|
534
|
+
}
|
|
467
535
|
finalStatus = await _pollToTerminal(this.client, {
|
|
468
536
|
workflowId: created.workflowId,
|
|
469
537
|
deadline,
|
|
@@ -477,9 +545,14 @@ export class Recipe {
|
|
|
477
545
|
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
|
|
478
546
|
}
|
|
479
547
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
548
|
+
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
549
|
+
// cannot return a success past the advertised maxWait deadline.
|
|
550
|
+
if (Date.now() >= deadline) {
|
|
551
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
|
|
552
|
+
}
|
|
480
553
|
// Download URLs from getWorkflowDownloads are pre-signed and require no SDK
|
|
481
554
|
// auth, so the downloader issues a plain unauthenticated fetch.
|
|
482
|
-
const downloader = new
|
|
555
|
+
const downloader = new LazyHttpDownloader();
|
|
483
556
|
return projectDownloadsToRunResult(created.workflowId, finalStatus, downloads.downloads, this.recipeKey ?? null, downloader);
|
|
484
557
|
}
|
|
485
558
|
/**
|
|
@@ -658,6 +731,40 @@ export class FilesRecipe {
|
|
|
658
731
|
textWatermark(text) {
|
|
659
732
|
return this.withStep(this.baseRecipe().textWatermark(text));
|
|
660
733
|
}
|
|
734
|
+
/**
|
|
735
|
+
* Combine the inputs into ONE output (N→1), in array order (FF3b). Returns a
|
|
736
|
+
* single-output {@link MergedRecipe} you chain further ops on
|
|
737
|
+
* (`files([...]).merge().compress()`). Reuses the operation-first
|
|
738
|
+
* {@link MergeOptions} for the merge-level options, so the wire shape matches
|
|
739
|
+
* `client.merge([...], options)`.
|
|
740
|
+
*
|
|
741
|
+
* `merge()` must be the FIRST op on `files([...])` — per-file ops before a
|
|
742
|
+
* combine (compress-each-then-merge) are a separate follow-up, rejected here
|
|
743
|
+
* with `GislConfigError` reason `pre_merge_ops_unsupported`.
|
|
744
|
+
*/
|
|
745
|
+
merge(options = {}) {
|
|
746
|
+
if (this.steps.length !== 0) {
|
|
747
|
+
throw new GislConfigError('merge() must be the first operation on files([...]); applying per-file ops before a combine ' +
|
|
748
|
+
'(compress-each-then-merge) is not yet supported — call merge() directly, then chain ops on the merged output.', { reason: 'pre_merge_ops_unsupported' });
|
|
749
|
+
}
|
|
750
|
+
return new MergedRecipe(this.inputs, options, [], this.presetDefaults, this.scopedPresetDefaults, this.client);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Bundle the inputs into ONE archive (N→1, zip / tar.gz) — media-agnostic,
|
|
754
|
+
* inputs may mix types. Returns a terminal {@link ArchivedRecipe} (a zip is
|
|
755
|
+
* the final artefact — no post-bundle chain). `format` / `folderStructure` are
|
|
756
|
+
* optional; the server defaults to zip + flat.
|
|
757
|
+
*
|
|
758
|
+
* `archive()` must be the FIRST op on `files([...])` → `GislConfigError` reason
|
|
759
|
+
* `pre_archive_ops_unsupported` otherwise.
|
|
760
|
+
*/
|
|
761
|
+
archive(options = {}) {
|
|
762
|
+
if (this.steps.length !== 0) {
|
|
763
|
+
throw new GislConfigError('archive() must be the first operation on files([...]); applying per-file ops before a bundle ' +
|
|
764
|
+
'is not yet supported — call archive() directly on the files you want to bundle.', { reason: 'pre_archive_ops_unsupported' });
|
|
765
|
+
}
|
|
766
|
+
return new ArchivedRecipe(this.inputs, options, this.client);
|
|
767
|
+
}
|
|
661
768
|
/** The number of inputs in this fan-out (introspection / tests). */
|
|
662
769
|
get inputCount() {
|
|
663
770
|
return this.inputs.length;
|
|
@@ -725,12 +832,9 @@ export class FilesRecipe {
|
|
|
725
832
|
});
|
|
726
833
|
}
|
|
727
834
|
catch (err) {
|
|
728
|
-
if (err instanceof
|
|
729
|
-
throw err;
|
|
730
|
-
if (err instanceof DOMException && err.name === 'AbortError')
|
|
731
|
-
throw err;
|
|
732
|
-
if (err instanceof GislApiError)
|
|
835
|
+
if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
|
|
733
836
|
throw err;
|
|
837
|
+
}
|
|
734
838
|
finalStatus = await _pollToTerminal(this.client, {
|
|
735
839
|
workflowId: created.workflowId,
|
|
736
840
|
deadline,
|
|
@@ -743,12 +847,17 @@ export class FilesRecipe {
|
|
|
743
847
|
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
|
|
744
848
|
}
|
|
745
849
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
850
|
+
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
851
|
+
// cannot return a success past the advertised maxWait deadline.
|
|
852
|
+
if (Date.now() >= deadline) {
|
|
853
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
|
|
854
|
+
}
|
|
746
855
|
// keyByRef maps each job ref ("file-{i}") to the partition key. Today the
|
|
747
856
|
// key is just the index string; the Map seam leaves room for the FF3b
|
|
748
857
|
// keyed-fan-out card to map refs to caller-supplied keys without changing
|
|
749
858
|
// the producer's signature.
|
|
750
859
|
const keyByRef = new Map(this.inputs.map((_, i) => [`file-${i}`, String(i)]));
|
|
751
|
-
const downloader = new
|
|
860
|
+
const downloader = new LazyHttpDownloader();
|
|
752
861
|
return projectMultiJobToRunResult(created.workflowId, finalStatus, downloads.downloads, keyByRef, downloader);
|
|
753
862
|
}
|
|
754
863
|
/**
|
|
@@ -838,3 +947,458 @@ export class FilesRecipe {
|
|
|
838
947
|
return new FilesRecipe(this.inputs, recipeWithStep.recipeSteps, this.presetDefaults, this.scopedPresetDefaults, this.client);
|
|
839
948
|
}
|
|
840
949
|
}
|
|
950
|
+
/**
|
|
951
|
+
* The single-output recipe you're in AFTER a fluent `files([...]).merge(...)`
|
|
952
|
+
* (FF3b). Merge collapses the N inputs into ONE output, so the per-file ops
|
|
953
|
+
* ({@link FilesRecipe.compress} etc.) no longer apply — instead this exposes the
|
|
954
|
+
* SAME chain ops as the single-file {@link Recipe}, applied to the merged
|
|
955
|
+
* result. `files([...]).merge().compress()` is the flagship case (example 14).
|
|
956
|
+
*
|
|
957
|
+
* **Lowering (one workflow):** each input is uploaded once and wrapped in its
|
|
958
|
+
* own single-input `passthrough` source job (`src_N`); the `merge` job consumes
|
|
959
|
+
* those via `job_output` inputs (array order = play order) and carries the merge
|
|
960
|
+
* op FIRST in its `operations[]`, followed by any post-combine ops (compress /
|
|
961
|
+
* convert / thumbnail) so they run on the merged output in the same job. The
|
|
962
|
+
* merge-level wire options reuse {@link wireMergeOptions} so a fluent merge
|
|
963
|
+
* lowers identically to the operation-first `client.merge()`.
|
|
964
|
+
*
|
|
965
|
+
* Immutable / clone-on-write like {@link Recipe} / {@link FilesRecipe}. Mirrors
|
|
966
|
+
* the PHP `MergedRecipe` in `packages/php/src/FileFirst/MergedRecipe.php`.
|
|
967
|
+
*/
|
|
968
|
+
export class MergedRecipe {
|
|
969
|
+
inputs;
|
|
970
|
+
mergeOptions;
|
|
971
|
+
postSteps;
|
|
972
|
+
presetDefaults;
|
|
973
|
+
scopedPresetDefaults;
|
|
974
|
+
client;
|
|
975
|
+
constructor(inputs, mergeOptions, postSteps = [], presetDefaults, scopedPresetDefaults, client) {
|
|
976
|
+
this.inputs = inputs;
|
|
977
|
+
this.mergeOptions = mergeOptions;
|
|
978
|
+
this.postSteps = postSteps;
|
|
979
|
+
this.presetDefaults = presetDefaults;
|
|
980
|
+
this.scopedPresetDefaults = scopedPresetDefaults;
|
|
981
|
+
this.client = client;
|
|
982
|
+
}
|
|
983
|
+
/** Reduce the merged output's size. See {@link Recipe.compress}. */
|
|
984
|
+
compress(optimize) {
|
|
985
|
+
if (optimize !== undefined && !Object.values(OptimizeFor).includes(optimize)) {
|
|
986
|
+
const allowed = Object.values(OptimizeFor).join(', ');
|
|
987
|
+
throw new GislConfigError(`compress 'optimize' must be one of ${allowed}; got '${String(optimize)}'.`, { reason: 'invalid_optimize', conflictingFields: ['optimize'] });
|
|
988
|
+
}
|
|
989
|
+
return this.withStep({ opType: 'compress', options: optimize === undefined ? {} : { optimize } });
|
|
990
|
+
}
|
|
991
|
+
/** Change the merged output's format. See {@link Recipe.convert}. */
|
|
992
|
+
convert(format) {
|
|
993
|
+
return this.withStep({ opType: 'convert', options: { format } });
|
|
994
|
+
}
|
|
995
|
+
/** Thumbnail the merged output. Omitted dimensions are dropped from the wire options. */
|
|
996
|
+
thumbnail(options = {}) {
|
|
997
|
+
const wire = {};
|
|
998
|
+
if (options.width !== undefined)
|
|
999
|
+
wire.width = options.width;
|
|
1000
|
+
if (options.height !== undefined)
|
|
1001
|
+
wire.height = options.height;
|
|
1002
|
+
return this.withStep({ opType: 'thumbnail', options: wire });
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Lower to the merge DAG: one `passthrough` source job per input + one
|
|
1006
|
+
* `merge` job whose `operations[]` is `[merge, ...post-combine ops]`. The
|
|
1007
|
+
* merge job's `inputs[]` consume the source jobs via `job_output` in input
|
|
1008
|
+
* (play) order.
|
|
1009
|
+
*
|
|
1010
|
+
* @internal Consumed by {@link run} (after uploading all inputs), {@link submit}
|
|
1011
|
+
* (with a webhook), and the cross-language parity harness (with fixed ids).
|
|
1012
|
+
*/
|
|
1013
|
+
toWorkflowPayload(fileIds, callbackUrl) {
|
|
1014
|
+
const mediaKind = this.inferMediaKind();
|
|
1015
|
+
const sourceJobs = [];
|
|
1016
|
+
const inputs = [];
|
|
1017
|
+
fileIds.forEach((fileId, i) => {
|
|
1018
|
+
const srcId = `src_${i}`;
|
|
1019
|
+
// Key order (id, source, operations) matches the PHP `toWire()` so the
|
|
1020
|
+
// JSON-string serialisation is byte-identical across languages.
|
|
1021
|
+
sourceJobs.push({ id: srcId, source: uploadSource(fileId), operations: [{ type: 'passthrough' }] });
|
|
1022
|
+
inputs.push({ source: jobOutputSource(srcId) });
|
|
1023
|
+
});
|
|
1024
|
+
const operations = [
|
|
1025
|
+
{ type: 'merge', options: wireMergeOptions(this.mergeOptions, mediaKind) },
|
|
1026
|
+
...this.lowerPostSteps(mediaKind),
|
|
1027
|
+
];
|
|
1028
|
+
const mergeJob = { id: 'merge', inputs, operations };
|
|
1029
|
+
const jobs = [...sourceJobs, mergeJob];
|
|
1030
|
+
return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
|
|
1031
|
+
}
|
|
1032
|
+
/** The number of inputs being combined (introspection / tests). */
|
|
1033
|
+
get inputCount() {
|
|
1034
|
+
return this.inputs.length;
|
|
1035
|
+
}
|
|
1036
|
+
/** The number of post-combine ops chained so far (introspection / tests). */
|
|
1037
|
+
get stepCount() {
|
|
1038
|
+
return this.postSteps.length;
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Execute end-to-end: upload every input, create the merge workflow, await a
|
|
1042
|
+
* terminal state (SSE with poll fallback), then resolve ONLY the merged output
|
|
1043
|
+
* into a {@link RunResult}. Throws {@link GislTimeoutError} on `maxWait`.
|
|
1044
|
+
*
|
|
1045
|
+
* Requires a client bound at construction time — `gisl().files(...).merge(...)`
|
|
1046
|
+
* wires it; a directly-constructed `MergedRecipe` throws {@link GislConfigError}.
|
|
1047
|
+
* Mirrors the single-file {@link Recipe.run}.
|
|
1048
|
+
*/
|
|
1049
|
+
async run(options = {}) {
|
|
1050
|
+
const signal = options.signal;
|
|
1051
|
+
const onProgress = options.onProgress;
|
|
1052
|
+
if (this.client === undefined) {
|
|
1053
|
+
throw new GislConfigError('MergedRecipe.run() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
|
|
1054
|
+
}
|
|
1055
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
|
|
1056
|
+
const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal);
|
|
1057
|
+
let finalStatus;
|
|
1058
|
+
try {
|
|
1059
|
+
finalStatus = await _consumeSseToTerminal(this.client, {
|
|
1060
|
+
workflowId: created.workflowId,
|
|
1061
|
+
deadline,
|
|
1062
|
+
signal,
|
|
1063
|
+
onProgress,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
catch (err) {
|
|
1067
|
+
if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
|
|
1068
|
+
throw err;
|
|
1069
|
+
}
|
|
1070
|
+
finalStatus = await _pollToTerminal(this.client, {
|
|
1071
|
+
workflowId: created.workflowId,
|
|
1072
|
+
deadline,
|
|
1073
|
+
signal,
|
|
1074
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
if (Date.now() >= deadline) {
|
|
1078
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
|
|
1079
|
+
}
|
|
1080
|
+
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
1081
|
+
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
1082
|
+
// cannot return a success past the advertised maxWait deadline.
|
|
1083
|
+
if (Date.now() >= deadline) {
|
|
1084
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
|
|
1085
|
+
}
|
|
1086
|
+
// Project ONLY the merge job's output — the `src_*` passthrough jobs
|
|
1087
|
+
// re-expose the raw uploads, which are plumbing, not the deliverable
|
|
1088
|
+
// (mirrors the operation-first merge.ts `ref === 'merge'` filter + PHP).
|
|
1089
|
+
const mergeDownloads = downloads.downloads.filter((d) => d.ref === 'merge');
|
|
1090
|
+
const downloader = new LazyHttpDownloader();
|
|
1091
|
+
return projectDownloadsToRunResult(created.workflowId, finalStatus, mergeDownloads, null, downloader);
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Fire-and-forget: upload + create the merge workflow (wiring `webhook` into
|
|
1095
|
+
* `callback_url` when given), return a client-bound {@link Handle}. Does NOT
|
|
1096
|
+
* wait for terminal status. Mirrors {@link Recipe.submit}.
|
|
1097
|
+
*/
|
|
1098
|
+
async submit(webhook) {
|
|
1099
|
+
if (this.client === undefined) {
|
|
1100
|
+
throw new GislConfigError('MergedRecipe.submit() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
|
|
1101
|
+
}
|
|
1102
|
+
const created = await this._uploadAllAndCreate(webhook, undefined);
|
|
1103
|
+
return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
|
|
1104
|
+
}
|
|
1105
|
+
// ---------------------------------------------------------------------------
|
|
1106
|
+
/**
|
|
1107
|
+
* Upload every input (verbatim for a pre-uploaded id; uploading a path / blob
|
|
1108
|
+
* otherwise, emitting `{phase:'upload'}` progress) then create ONE merge
|
|
1109
|
+
* workflow. Rejects fewer than 2 inputs BEFORE any upload fires. Shared first
|
|
1110
|
+
* half of {@link run} + {@link submit}.
|
|
1111
|
+
*/
|
|
1112
|
+
async _uploadAllAndCreate(webhook, deadline, onProgress, signal) {
|
|
1113
|
+
this.validatePreUpload();
|
|
1114
|
+
const fileIds = [];
|
|
1115
|
+
for (const input of this.inputs) {
|
|
1116
|
+
_checkAborted(signal);
|
|
1117
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
1118
|
+
throw new GislTimeoutError('maxWait elapsed during merge uploads before all inputs were uploaded');
|
|
1119
|
+
}
|
|
1120
|
+
if (input.kind === 'uploadId') {
|
|
1121
|
+
fileIds.push(input.fileId);
|
|
1122
|
+
}
|
|
1123
|
+
else {
|
|
1124
|
+
const source = input.kind === 'path' ? input.path : input.blob;
|
|
1125
|
+
const up = await this.client.uploadFile(source, {
|
|
1126
|
+
signal,
|
|
1127
|
+
...(onProgress !== undefined
|
|
1128
|
+
? {
|
|
1129
|
+
onProgress: (uploadedBytes, totalBytes) => {
|
|
1130
|
+
onProgress({ phase: 'upload', uploadedBytes, totalBytes });
|
|
1131
|
+
},
|
|
1132
|
+
}
|
|
1133
|
+
: {}),
|
|
1134
|
+
});
|
|
1135
|
+
fileIds.push(up.fileId);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
_checkAborted(signal);
|
|
1139
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
1140
|
+
throw new GislTimeoutError('Uploads completed but maxWait elapsed before the merge workflow could be created');
|
|
1141
|
+
}
|
|
1142
|
+
const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
|
|
1143
|
+
_checkAborted(signal);
|
|
1144
|
+
return created;
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Reject an invalid combine BEFORE any upload fires — mirrors the operation-
|
|
1148
|
+
* first `MergeBuilder.planSequence()` bounds so a typo'd merge costs no
|
|
1149
|
+
* bandwidth: 2–10 inputs (merge schema `min/max_inputs`), and an image merge
|
|
1150
|
+
* must carry an explicit `output_type` (the server rejects image merges
|
|
1151
|
+
* without one). Shared by {@link run} + {@link submit} via
|
|
1152
|
+
* {@link _uploadAllAndCreate}.
|
|
1153
|
+
*/
|
|
1154
|
+
validatePreUpload() {
|
|
1155
|
+
if (this.inputs.length < 2) {
|
|
1156
|
+
throw new GislConfigError(`merge requires at least 2 inputs to combine (got ${this.inputs.length}).`, { reason: 'too_few_inputs' });
|
|
1157
|
+
}
|
|
1158
|
+
if (this.inputs.length > 10) {
|
|
1159
|
+
throw new GislConfigError(`merge accepts at most 10 inputs (got ${this.inputs.length}). Split the merge or reduce the input list.`, { reason: 'too_many_inputs' });
|
|
1160
|
+
}
|
|
1161
|
+
if (this.inferMediaKind() === 'image' &&
|
|
1162
|
+
this.mergeOptions.output === undefined &&
|
|
1163
|
+
this.mergeOptions.outputType === undefined) {
|
|
1164
|
+
throw new GislConfigError('image merges require an explicit output_type — set MergeOptions output: "video" | "gif" (or outputType). ' +
|
|
1165
|
+
'The server rejects image merge requests with no output_type.', { reason: 'image_merge_requires_output_type' });
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Lower the post-combine chain by composing a single-file {@link Recipe} over a
|
|
1170
|
+
* synthetic input whose extension matches the merged OUTPUT media — so
|
|
1171
|
+
* `compress(optimize)` resolves the correct preset for the merged result (it
|
|
1172
|
+
* needs a media hint, which a merge output carries no filename for). Reuses
|
|
1173
|
+
* Recipe's `lowerStep` rather than duplicating it.
|
|
1174
|
+
*/
|
|
1175
|
+
lowerPostSteps(mediaKind) {
|
|
1176
|
+
if (this.postSteps.length === 0) {
|
|
1177
|
+
return [];
|
|
1178
|
+
}
|
|
1179
|
+
const synthetic = fileInput.path(`merged.${this.outputExtensionFor(mediaKind)}`);
|
|
1180
|
+
const recipe = new Recipe(synthetic, undefined, this.postSteps, this.presetDefaults, this.scopedPresetDefaults);
|
|
1181
|
+
return recipe.toWorkflowPayload('merged').jobs[0].operations;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* The merged-output media. Honours an explicit {@link MergeOptions.mediaKind};
|
|
1185
|
+
* otherwise infers from the first PATH input's extension (mirrors
|
|
1186
|
+
* {@link MergeBuilder}); defaults to video.
|
|
1187
|
+
*/
|
|
1188
|
+
inferMediaKind() {
|
|
1189
|
+
if (this.mergeOptions.mediaKind !== undefined) {
|
|
1190
|
+
return this.mergeOptions.mediaKind;
|
|
1191
|
+
}
|
|
1192
|
+
// Sniff the first input carrying a media signal — a path extension or a
|
|
1193
|
+
// Blob MIME type (mirrors the operation-first MergeBuilder.inferMediaKind,
|
|
1194
|
+
// codex c2). Pre-uploaded ids carry no signal, so they are skipped.
|
|
1195
|
+
for (const input of this.inputs) {
|
|
1196
|
+
if (input.kind === 'path') {
|
|
1197
|
+
const lower = input.path.toLowerCase();
|
|
1198
|
+
if (/\.(jpe?g|png|webp|avif|gif|heic|tiff?)$/.test(lower))
|
|
1199
|
+
return 'image';
|
|
1200
|
+
if (/\.(mp3|wav|flac|aac|ogg|m4a)$/.test(lower))
|
|
1201
|
+
return 'audio';
|
|
1202
|
+
return 'video';
|
|
1203
|
+
}
|
|
1204
|
+
if (input.kind === 'blob') {
|
|
1205
|
+
if (input.blob.type.startsWith('image/'))
|
|
1206
|
+
return 'image';
|
|
1207
|
+
if (input.blob.type.startsWith('audio/'))
|
|
1208
|
+
return 'audio';
|
|
1209
|
+
return 'video';
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return 'video';
|
|
1213
|
+
}
|
|
1214
|
+
outputExtensionFor(mediaKind) {
|
|
1215
|
+
// An image merge produces a video/gif output (output_type), so the
|
|
1216
|
+
// post-combine media follows the output type when set.
|
|
1217
|
+
const output = this.mergeOptions.output ?? this.mergeOptions.outputType;
|
|
1218
|
+
if (mediaKind === 'image' && typeof output === 'string') {
|
|
1219
|
+
return output === 'gif' ? 'gif' : 'mp4';
|
|
1220
|
+
}
|
|
1221
|
+
switch (mediaKind) {
|
|
1222
|
+
case 'audio':
|
|
1223
|
+
return 'mp3';
|
|
1224
|
+
case 'image':
|
|
1225
|
+
return 'png';
|
|
1226
|
+
default:
|
|
1227
|
+
return 'mp4';
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
withStep(step) {
|
|
1231
|
+
return new MergedRecipe(this.inputs, this.mergeOptions, [...this.postSteps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* The single-output recipe you're in AFTER a fluent `files([...]).archive(...)`
|
|
1236
|
+
* (FF3b). Archive bundles the N inputs into ONE downloadable archive (zip /
|
|
1237
|
+
* tar.gz) — media-agnostic, inputs may mix types. Unlike {@link MergedRecipe},
|
|
1238
|
+
* archive is TERMINAL: a zip is the final artefact, so there is no post-bundle
|
|
1239
|
+
* chain — this exposes only `run()` / `submit()`.
|
|
1240
|
+
*
|
|
1241
|
+
* **Lowering (one workflow):** each input is uploaded once and wrapped in its
|
|
1242
|
+
* own single-input `passthrough` source job (`src_N`); the `archive` job
|
|
1243
|
+
* consumes those via `job_output` inputs (array order = entry order) and carries
|
|
1244
|
+
* the single `archive` op. The archive job's id is `archive`, so {@link RunResult}
|
|
1245
|
+
* projects ONLY its output. Mirrors the PHP `ArchivedRecipe`.
|
|
1246
|
+
*/
|
|
1247
|
+
export class ArchivedRecipe {
|
|
1248
|
+
inputs;
|
|
1249
|
+
options;
|
|
1250
|
+
client;
|
|
1251
|
+
constructor(inputs, options = {}, client) {
|
|
1252
|
+
this.inputs = inputs;
|
|
1253
|
+
this.options = options;
|
|
1254
|
+
this.client = client;
|
|
1255
|
+
}
|
|
1256
|
+
/** The number of inputs being bundled (introspection / tests). */
|
|
1257
|
+
get inputCount() {
|
|
1258
|
+
return this.inputs.length;
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Lower to the archive DAG: one `passthrough` source job per input + one
|
|
1262
|
+
* `archive` job consuming them via `job_output`.
|
|
1263
|
+
*
|
|
1264
|
+
* @internal Consumed by {@link run} / {@link submit} (after uploading) and the
|
|
1265
|
+
* cross-language parity harness (with fixed ids).
|
|
1266
|
+
*/
|
|
1267
|
+
toWorkflowPayload(fileIds, callbackUrl) {
|
|
1268
|
+
const sourceJobs = [];
|
|
1269
|
+
const inputs = [];
|
|
1270
|
+
fileIds.forEach((fileId, i) => {
|
|
1271
|
+
const srcId = `src_${i}`;
|
|
1272
|
+
sourceJobs.push({ id: srcId, source: uploadSource(fileId), operations: [{ type: 'passthrough' }] });
|
|
1273
|
+
inputs.push({ source: jobOutputSource(srcId) });
|
|
1274
|
+
});
|
|
1275
|
+
const archiveJob = {
|
|
1276
|
+
id: 'archive',
|
|
1277
|
+
inputs,
|
|
1278
|
+
operations: [{ type: 'archive', options: this.wireArchiveOptions() }],
|
|
1279
|
+
};
|
|
1280
|
+
const jobs = [...sourceJobs, archiveJob];
|
|
1281
|
+
return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Execute end-to-end: upload every input, create the archive workflow, await a
|
|
1285
|
+
* terminal state (SSE with poll fallback), then resolve ONLY the archive output
|
|
1286
|
+
* into a {@link RunResult}. Throws {@link GislTimeoutError} on `maxWait`.
|
|
1287
|
+
* Requires a client bound via `gisl().files(...).archive(...)`.
|
|
1288
|
+
*/
|
|
1289
|
+
async run(options = {}) {
|
|
1290
|
+
const signal = options.signal;
|
|
1291
|
+
const onProgress = options.onProgress;
|
|
1292
|
+
if (this.client === undefined) {
|
|
1293
|
+
throw new GislConfigError('ArchivedRecipe.run() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
|
|
1294
|
+
}
|
|
1295
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
|
|
1296
|
+
const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal);
|
|
1297
|
+
let finalStatus;
|
|
1298
|
+
try {
|
|
1299
|
+
finalStatus = await _consumeSseToTerminal(this.client, {
|
|
1300
|
+
workflowId: created.workflowId,
|
|
1301
|
+
deadline,
|
|
1302
|
+
signal,
|
|
1303
|
+
onProgress,
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
catch (err) {
|
|
1307
|
+
if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
|
|
1308
|
+
throw err;
|
|
1309
|
+
}
|
|
1310
|
+
finalStatus = await _pollToTerminal(this.client, {
|
|
1311
|
+
workflowId: created.workflowId,
|
|
1312
|
+
deadline,
|
|
1313
|
+
signal,
|
|
1314
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
if (Date.now() >= deadline) {
|
|
1318
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
|
|
1319
|
+
}
|
|
1320
|
+
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
1321
|
+
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
1322
|
+
// cannot return a success past the advertised maxWait deadline.
|
|
1323
|
+
if (Date.now() >= deadline) {
|
|
1324
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
|
|
1325
|
+
}
|
|
1326
|
+
// Project ONLY the archive job's output — the `src_*` passthrough jobs
|
|
1327
|
+
// re-expose the raw uploads, which are plumbing, not the deliverable.
|
|
1328
|
+
const archiveDownloads = downloads.downloads.filter((d) => d.ref === 'archive');
|
|
1329
|
+
const downloader = new LazyHttpDownloader();
|
|
1330
|
+
return projectDownloadsToRunResult(created.workflowId, finalStatus, archiveDownloads, null, downloader);
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Fire-and-forget: upload + create the archive workflow (wiring `webhook` into
|
|
1334
|
+
* `callback_url` when given), return a client-bound {@link Handle}. Mirrors
|
|
1335
|
+
* {@link MergedRecipe.submit}.
|
|
1336
|
+
*/
|
|
1337
|
+
async submit(webhook) {
|
|
1338
|
+
if (this.client === undefined) {
|
|
1339
|
+
throw new GislConfigError('ArchivedRecipe.submit() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
|
|
1340
|
+
}
|
|
1341
|
+
const created = await this._uploadAllAndCreate(webhook, undefined);
|
|
1342
|
+
return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
|
|
1343
|
+
}
|
|
1344
|
+
// ---------------------------------------------------------------------------
|
|
1345
|
+
async _uploadAllAndCreate(webhook, deadline, onProgress, signal) {
|
|
1346
|
+
this.validatePreUpload();
|
|
1347
|
+
const fileIds = [];
|
|
1348
|
+
for (const input of this.inputs) {
|
|
1349
|
+
_checkAborted(signal);
|
|
1350
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
1351
|
+
throw new GislTimeoutError('maxWait elapsed during archive uploads before all inputs were uploaded');
|
|
1352
|
+
}
|
|
1353
|
+
if (input.kind === 'uploadId') {
|
|
1354
|
+
fileIds.push(input.fileId);
|
|
1355
|
+
}
|
|
1356
|
+
else {
|
|
1357
|
+
const source = input.kind === 'path' ? input.path : input.blob;
|
|
1358
|
+
const up = await this.client.uploadFile(source, {
|
|
1359
|
+
signal,
|
|
1360
|
+
...(onProgress !== undefined
|
|
1361
|
+
? {
|
|
1362
|
+
onProgress: (uploadedBytes, totalBytes) => {
|
|
1363
|
+
onProgress({ phase: 'upload', uploadedBytes, totalBytes });
|
|
1364
|
+
},
|
|
1365
|
+
}
|
|
1366
|
+
: {}),
|
|
1367
|
+
});
|
|
1368
|
+
fileIds.push(up.fileId);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
_checkAborted(signal);
|
|
1372
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
1373
|
+
throw new GislTimeoutError('Uploads completed but maxWait elapsed before the archive workflow could be created');
|
|
1374
|
+
}
|
|
1375
|
+
const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
|
|
1376
|
+
_checkAborted(signal);
|
|
1377
|
+
return created;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Reject an invalid bundle BEFORE any upload fires — the archive schema allows
|
|
1381
|
+
* 2–50 inputs (`min/max_inputs`), so a typo'd bundle costs no bandwidth.
|
|
1382
|
+
*/
|
|
1383
|
+
validatePreUpload() {
|
|
1384
|
+
if (this.inputs.length < 2) {
|
|
1385
|
+
throw new GislConfigError(`archive requires at least 2 inputs to bundle (got ${this.inputs.length}).`, { reason: 'too_few_inputs' });
|
|
1386
|
+
}
|
|
1387
|
+
if (this.inputs.length > 50) {
|
|
1388
|
+
throw new GislConfigError(`archive accepts at most 50 inputs (got ${this.inputs.length}). Split the bundle or reduce the input list.`, { reason: 'too_many_inputs' });
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Project the archive options into the wire shape. Both fields are optional
|
|
1393
|
+
* (the server defaults `format` to zip and `folder_structure` to flat), so an
|
|
1394
|
+
* omitted option is dropped rather than sent.
|
|
1395
|
+
*/
|
|
1396
|
+
wireArchiveOptions() {
|
|
1397
|
+
const out = {};
|
|
1398
|
+
if (this.options.format !== undefined)
|
|
1399
|
+
out.format = this.options.format;
|
|
1400
|
+
if (this.options.folderStructure !== undefined)
|
|
1401
|
+
out.folder_structure = this.options.folderStructure;
|
|
1402
|
+
return out;
|
|
1403
|
+
}
|
|
1404
|
+
}
|