@giveitsmaller/sdk 0.21.0 → 0.22.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 +48 -0
- package/dist/builder.js +31 -6
- package/dist/ergonomic/image_output_routes.d.ts +67 -0
- package/dist/ergonomic/image_output_routes.js +146 -15
- package/dist/ergonomic/option_types.d.ts +11 -2
- package/dist/ergonomic/preset_resolver.d.ts +22 -0
- package/dist/ergonomic/preset_resolver.js +94 -0
- package/dist/ergonomic/presets/image_compress.js +16 -4
- package/dist/ergonomic/presets/video_compress.d.ts +14 -0
- package/dist/errors.d.ts +53 -0
- package/dist/errors.js +54 -0
- package/dist/file-first.js +51 -6
- package/dist/generated/sdk_spec/errors.d.ts +1 -1
- package/dist/generated/sdk_spec/errors.js +147 -1
- package/dist/index.core.d.ts +1 -1
- package/dist/index.core.js +4 -1
- package/dist/merge.d.ts +23 -0
- package/dist/sse.js +49 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -56,6 +56,54 @@ for (const artifact of many.artifacts) console.log(artifact.url);
|
|
|
56
56
|
> only under the same auth that created it; the upload-then-create flow above is
|
|
57
57
|
> consistent by construction. Anonymous-intake uploads are unaffected.
|
|
58
58
|
|
|
59
|
+
## Known limitation — browser SSE is restricted to one origin
|
|
60
|
+
|
|
61
|
+
**Applies to the browser build only** (`@giveitsmaller/sdk/browser`), and **only to live progress
|
|
62
|
+
streaming** — `streamEvents()`, and the `run({ useSSE: true })` default that uses it.
|
|
63
|
+
|
|
64
|
+
Live progress is served from a **separate host** to the rest of the API, and that host allows
|
|
65
|
+
**exactly one browser origin**: the Give It Smaller web app. So from a browser on any other origin:
|
|
66
|
+
|
|
67
|
+
| what you are doing | works? |
|
|
68
|
+
|---|---|
|
|
69
|
+
| Uploads, workflow create, status, downloads | **Yes** — these stay on the main API host, which allows a **list** of origins (per environment) |
|
|
70
|
+
| `streamEvents()` / SSE progress | **No** — blocked at the CORS preflight |
|
|
71
|
+
|
|
72
|
+
**Affected consumers:** third-party sites embedding the SDK, embedded/iframe use, and **local
|
|
73
|
+
development against staging** — which is the one most likely to bite first, because it looks like a
|
|
74
|
+
bug in your code.
|
|
75
|
+
|
|
76
|
+
⚠️ **Local dev against staging is the sharp edge, and the reason is worth stating:** the two hosts
|
|
77
|
+
have *different* CORS policies, for a platform reason rather than an oversight. The main API host
|
|
78
|
+
allows a **list** of origins — and the **staging** list includes the usual localhost dev ports — so a
|
|
79
|
+
browser on `localhost` works against staging today. The stream host allows exactly **one** origin,
|
|
80
|
+
because it is a different API product with no native CORS configuration and nowhere to put a second
|
|
81
|
+
value. So everything keeps working right up until live progress, and then fails with a CORS error —
|
|
82
|
+
which reads like a mistake in your own application rather than a platform limitation.
|
|
83
|
+
|
|
84
|
+
**Production allows only the production web app on both hosts**, and always has.
|
|
85
|
+
|
|
86
|
+
**Workaround:** pass `useSSE: false` to `run()`. The SDK falls back to polling, which goes to the
|
|
87
|
+
main API host and is unaffected. Everything else about the call is identical.
|
|
88
|
+
|
|
89
|
+
**Why it cannot simply be widened:** the stream is cookie-credentialed, and the CORS specification
|
|
90
|
+
forbids combining `Access-Control-Allow-Credentials: true` with `Access-Control-Allow-Origin: *`.
|
|
91
|
+
The header also accepts exactly one origin — a comma-separated list is not valid. Supporting more
|
|
92
|
+
origins requires the server to validate and echo the request's `Origin`, which is planned but not
|
|
93
|
+
shipped.
|
|
94
|
+
|
|
95
|
+
**On authentication:** prefer an API key (`bearerAuth`) or the anonymous capability token on the
|
|
96
|
+
stream host. Cookie/session auth is accepted by the endpoint but a *credentialed cross-origin*
|
|
97
|
+
request additionally needs the browser to opt in and the server to answer with matching credential
|
|
98
|
+
headers — cookie domain scope alone is not sufficient, and this path is not verified.
|
|
99
|
+
|
|
100
|
+
**Node consumers are unaffected** — CORS is a browser mechanism. The PHP SDK is unaffected for the
|
|
101
|
+
same reason.
|
|
102
|
+
|
|
103
|
+
> ⚠️ This limitation is invisible to automated testing: our own app's origin is allowed, so every
|
|
104
|
+
> test and canary we run passes while a consumer on another origin fails. It is written here because
|
|
105
|
+
> nothing else would tell you.
|
|
106
|
+
|
|
59
107
|
## Documentation
|
|
60
108
|
|
|
61
109
|
Full documentation — getting started and concepts, the `GislClient` reference and operation
|
package/dist/builder.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { SseEventType, SseOperationProgressDataFromJSON, } from '@giveitsmaller/contracts/openapi';
|
|
28
28
|
import { uploadSource } from './types.js';
|
|
29
|
-
import { GislTimeoutError, GislNetworkError, SseEndedWithoutTerminal } from './errors.js';
|
|
29
|
+
import { GislTimeoutError, GislFanOutTimeoutError, GislNetworkError, SseEndedWithoutTerminal } from './errors.js';
|
|
30
30
|
// Deferred-usage-only import: `Handle` is constructed inside submit() at call
|
|
31
31
|
// time, not at module load, so the builder.ts <-> handle.ts cycle is safe
|
|
32
32
|
// under ESM (handle.ts imports the await-primitives from this module).
|
|
@@ -446,13 +446,38 @@ export class MapEachBuilder {
|
|
|
446
446
|
_checkAborted(options.signal);
|
|
447
447
|
const remaining = deadline - Date.now();
|
|
448
448
|
if (remaining <= 0) {
|
|
449
|
-
|
|
449
|
+
// Clean timeout BETWEEN children (no child in flight): the parent + the
|
|
450
|
+
// children completed so far are recoverable — carry their ids so the
|
|
451
|
+
// caller polls them and re-runs ONLY the never-created children (4G4FaA9X).
|
|
452
|
+
throw new GislFanOutTimeoutError(`maxWait elapsed during fan-out (after ${collectedChildResults.length} child runs)`, {
|
|
453
|
+
completedWorkflowIds: collectedChildResults.map((r) => r.workflowId),
|
|
454
|
+
parentWorkflowId: parentResult.workflowId,
|
|
455
|
+
});
|
|
450
456
|
}
|
|
451
457
|
const childBuilder = this.fn(art);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
458
|
+
let childResult;
|
|
459
|
+
try {
|
|
460
|
+
childResult = await childBuilder.run({
|
|
461
|
+
...options,
|
|
462
|
+
maxWait: remaining,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
// A CHILD's own deadline elapsed mid-run — the COMMON fan-out timeout
|
|
467
|
+
// path. Re-throw as a fan-out timeout so the parent + already-completed
|
|
468
|
+
// children + this in-flight child are ALL recoverable, instead of losing
|
|
469
|
+
// them behind the child's bare GislTimeoutError (4G4FaA9X). Other errors
|
|
470
|
+
// (config / API / item failure) propagate unchanged.
|
|
471
|
+
if (err instanceof GislTimeoutError) {
|
|
472
|
+
throw new GislFanOutTimeoutError(`maxWait elapsed during fan-out while a child was running (${collectedChildResults.length} completed)`, {
|
|
473
|
+
completedWorkflowIds: collectedChildResults.map((r) => r.workflowId),
|
|
474
|
+
parentWorkflowId: parentResult.workflowId,
|
|
475
|
+
workflowId: err.workflowId,
|
|
476
|
+
cause: err,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
throw err;
|
|
480
|
+
}
|
|
456
481
|
collectedChildResults.push(childResult);
|
|
457
482
|
for (const childArt of childResult.artifacts)
|
|
458
483
|
collectedArtifacts.push(childArt);
|
|
@@ -85,6 +85,24 @@ export declare function resolveOutputRoute(inputToken: string, outputFormat: str
|
|
|
85
85
|
* `compressMetadata` `per_value_availability`; same_format only (the only route
|
|
86
86
|
* where value-level options like `metadata` are honored). Returns false when the
|
|
87
87
|
* option / value / group is unknown (no gate).
|
|
88
|
+
*
|
|
89
|
+
* PURELY ADDITIVE (SB1wmTJz): planned if ANY consulted group marks this value planned.
|
|
90
|
+
* The historical group is still consulted, so **every verdict this returned before still
|
|
91
|
+
* holds** — the change can only turn a missed gate into a gate, never a gate into a
|
|
92
|
+
* pass. That direction matters: a new false ACCEPT would send a request the server
|
|
93
|
+
* rejects, which is the failure this function exists to prevent.
|
|
94
|
+
*
|
|
95
|
+
* Why not "most specific wins", which reads cleaner: it would flip `webp` +
|
|
96
|
+
* `color_profile: 'srgb'` from gated to un-gated, because `image_webp` defines
|
|
97
|
+
* `color_profile` with an empty `per_value_availability`. `RecipeOutputTest`
|
|
98
|
+
* deliberately pins webp srgb as GATED (v2.134 added `srgb: planned` to the generic
|
|
99
|
+
* group), and whether webp srgb actually works on the server is not something this
|
|
100
|
+
* layer can know. Un-gating it on an inference would be exactly the "confident answer
|
|
101
|
+
* from a check that could not tell you otherwise" pattern. Raised as a question instead.
|
|
102
|
+
*
|
|
103
|
+
* What this DOES fix: `image_svg` marks `output_format: 'original'` planned and the
|
|
104
|
+
* generic group does not, so an SVG input previously sailed through the one marker that
|
|
105
|
+
* mattered for it — on this gate and on the `output()` gate that shares it.
|
|
88
106
|
*/
|
|
89
107
|
export declare function isPlannedValue(inputToken: string, optionKey: string, value: unknown): boolean;
|
|
90
108
|
/**
|
|
@@ -117,4 +135,53 @@ export declare const COMPRESS_OPTION_VALUES: Readonly<Record<string, Readonly<Re
|
|
|
117
135
|
* treated as unknown rather than coerced to a match.
|
|
118
136
|
*/
|
|
119
137
|
export declare function isUnknownEnumValue(inputToken: string, optionKey: string, value: unknown): boolean;
|
|
138
|
+
/** A contract `depends_on` rule for a compress-image output option (ehHU08Hu). */
|
|
139
|
+
type OutputDependsOnRule = {
|
|
140
|
+
readonly requiresKey: string;
|
|
141
|
+
readonly requiresValue: string;
|
|
142
|
+
} | {
|
|
143
|
+
readonly requiresAnyOf: readonly string[];
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Contract `depends_on` per compress-image output option, mirroring
|
|
147
|
+
* `availability.json` `operations.compress.mime_groups.<group>.options.<opt>.depends_on`
|
|
148
|
+
* (ehHU08Hu). The rule is option-consistent across every image group that carries
|
|
149
|
+
* the option, so this is a FLAT table (validated group-by-group by
|
|
150
|
+
* `output-route-conformance.test.ts` / PHP `ImageOutputRouteConformanceTest`).
|
|
151
|
+
*
|
|
152
|
+
* Kept as a hand table — NOT a runtime read of the ~238KB availability sidecar —
|
|
153
|
+
* so the gate stays browser-safe with no contracts-version coupling, exactly like
|
|
154
|
+
* {@link COMPRESS_OPTION_VALUES}. Mirrored by PHP
|
|
155
|
+
* `ImageOutputRoutes::OUTPUT_OPTION_DEPENDS_ON`.
|
|
156
|
+
*
|
|
157
|
+
* Generalises the 86gAu5Tr auto_quality gate: every option's dependency is
|
|
158
|
+
* checked uniformly, so quality/lossless/target_size_bytes under `auto_quality`,
|
|
159
|
+
* `target_size_bytes` without `target_size`, `fit` without width/height, etc. are
|
|
160
|
+
* all rejected pre-upload instead of only the one hand-coded case.
|
|
161
|
+
*/
|
|
162
|
+
export declare const OUTPUT_OPTION_DEPENDS_ON: Readonly<Record<string, OutputDependsOnRule>>;
|
|
163
|
+
/**
|
|
164
|
+
* Default of each depended-on key — an ABSENT key resolves to this before the
|
|
165
|
+
* dependency check (the server applies the same default). `encoding_mode`
|
|
166
|
+
* defaults to `quality`, so `quality`/`lossless` are valid with no explicit mode,
|
|
167
|
+
* but `target_size_bytes` / `quality_preset` are not. Pinned to `availability.json`
|
|
168
|
+
* defaults by the conformance suite.
|
|
169
|
+
*/
|
|
170
|
+
export declare const DEPENDS_ON_KEY_DEFAULTS: Readonly<Record<string, string>>;
|
|
171
|
+
/**
|
|
172
|
+
* The first contract `depends_on` an already-lowered compress-image wire-option
|
|
173
|
+
* set violates for the resolved `route`, or `undefined` when every dependency is
|
|
174
|
+
* satisfied (ehHU08Hu). The caller ({@link Recipe} output lowering) throws
|
|
175
|
+
* `invalid_option_combination` with the returned message + conflictingFields.
|
|
176
|
+
* Only options PRESENT in `wireOptions` are checked; a scalar dependency reads
|
|
177
|
+
* the depended-on key's effective value ({@link DEPENDS_ON_KEY_DEFAULTS} when
|
|
178
|
+
* absent). A scalar (encoding_mode) dependency is skipped on a `format_change`
|
|
179
|
+
* (convert has its own deps); universal deps (e.g. `fit → width|height`, identical
|
|
180
|
+
* in compress + convert) run on BOTH routes. Mirrored by PHP
|
|
181
|
+
* `ImageOutputRoutes::dependsOnViolation`.
|
|
182
|
+
*/
|
|
183
|
+
export declare function dependsOnViolation(wireOptions: Readonly<Record<string, unknown>>, route: 'same_format' | 'format_change'): {
|
|
184
|
+
readonly message: string;
|
|
185
|
+
readonly conflictingFields: readonly string[];
|
|
186
|
+
} | undefined;
|
|
120
187
|
export {};
|
|
@@ -146,15 +146,38 @@ export function resolveOutputRoute(inputToken, outputFormat) {
|
|
|
146
146
|
planned: new Set(cell.planned),
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Input token → EVERY `compress.image*` mime-group that can carry a per-value
|
|
151
|
+
* availability marker for it: the format-specific group when the metadata has one,
|
|
152
|
+
* PLUS the generic `image` group. Most specific first.
|
|
153
|
+
*
|
|
154
|
+
* Both are needed, and the old single-group version lost one or the other whichever
|
|
155
|
+
* way it chose (SB1wmTJz):
|
|
156
|
+
* - The generic group carries CROSS-FORMAT markers — `color_profile: 'srgb'` is planned
|
|
157
|
+
* there and nowhere else, so a lookup that resolved only to `image_jpeg` never saw it.
|
|
158
|
+
* - A specific group carries FORMAT-ONLY markers — `image_svg` marks
|
|
159
|
+
* `output_format: 'original'` planned (SVG→SVG optimisation is not built) and the
|
|
160
|
+
* generic group does not, so a lookup that resolved only to `image` never saw THAT.
|
|
161
|
+
*
|
|
162
|
+
* The previous implementation hard-coded `jpeg|png|avif` and fell through to `image`
|
|
163
|
+
* with a trailing `// webp / gif / svg / tiff`. That comment was true when written and
|
|
164
|
+
* silently stopped being true when `image_svg` and `image_webp` were added to the
|
|
165
|
+
* metadata — so SVG inputs missed the one marker that mattered for them, on this gate
|
|
166
|
+
* AND on the `output()` gate that shares it. Deriving the list from the metadata rather
|
|
167
|
+
* than a hand-written token list is what stops it going stale a second time; the
|
|
168
|
+
* mapping is pinned by `output-route-conformance.test.ts`.
|
|
169
|
+
*
|
|
170
|
+
* `gif`/`tiff` correctly yield `['image']` alone — the metadata genuinely has no
|
|
171
|
+
* concrete group for them (verified against its actual key set, not inferred).
|
|
172
|
+
*/
|
|
173
|
+
function compressGroupsForToken(token) {
|
|
174
|
+
// The historical mapping, PRESERVED EXACTLY. Every verdict it produced today must
|
|
175
|
+
// keep being produced — see the note on additivity in `isPlannedValue`.
|
|
176
|
+
const legacy = token === 'jpeg' ? 'image_jpeg' : token === 'png' ? 'image_png' : token === 'avif' ? 'image_avif' : 'image';
|
|
177
|
+
const specific = `image_${token}`;
|
|
178
|
+
return specific !== legacy && compressMetadata.mime_groups[specific] !== undefined
|
|
179
|
+
? [specific, legacy]
|
|
180
|
+
: [legacy];
|
|
158
181
|
}
|
|
159
182
|
/**
|
|
160
183
|
* Whether a specific VALUE of an option is `availability: 'planned'` for the
|
|
@@ -163,14 +186,32 @@ function compressGroupForToken(token) {
|
|
|
163
186
|
* `compressMetadata` `per_value_availability`; same_format only (the only route
|
|
164
187
|
* where value-level options like `metadata` are honored). Returns false when the
|
|
165
188
|
* option / value / group is unknown (no gate).
|
|
189
|
+
*
|
|
190
|
+
* PURELY ADDITIVE (SB1wmTJz): planned if ANY consulted group marks this value planned.
|
|
191
|
+
* The historical group is still consulted, so **every verdict this returned before still
|
|
192
|
+
* holds** — the change can only turn a missed gate into a gate, never a gate into a
|
|
193
|
+
* pass. That direction matters: a new false ACCEPT would send a request the server
|
|
194
|
+
* rejects, which is the failure this function exists to prevent.
|
|
195
|
+
*
|
|
196
|
+
* Why not "most specific wins", which reads cleaner: it would flip `webp` +
|
|
197
|
+
* `color_profile: 'srgb'` from gated to un-gated, because `image_webp` defines
|
|
198
|
+
* `color_profile` with an empty `per_value_availability`. `RecipeOutputTest`
|
|
199
|
+
* deliberately pins webp srgb as GATED (v2.134 added `srgb: planned` to the generic
|
|
200
|
+
* group), and whether webp srgb actually works on the server is not something this
|
|
201
|
+
* layer can know. Un-gating it on an inference would be exactly the "confident answer
|
|
202
|
+
* from a check that could not tell you otherwise" pattern. Raised as a question instead.
|
|
203
|
+
*
|
|
204
|
+
* What this DOES fix: `image_svg` marks `output_format: 'original'` planned and the
|
|
205
|
+
* generic group does not, so an SVG input previously sailed through the one marker that
|
|
206
|
+
* mattered for it — on this gate and on the `output()` gate that shares it.
|
|
166
207
|
*/
|
|
167
208
|
export function isPlannedValue(inputToken, optionKey, value) {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return
|
|
209
|
+
for (const groupName of compressGroupsForToken(inputToken)) {
|
|
210
|
+
const opt = compressMetadata.mime_groups[groupName]?.options[optionKey];
|
|
211
|
+
if (opt?.per_value_availability[String(value)]?.availability === 'planned')
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
174
215
|
}
|
|
175
216
|
/**
|
|
176
217
|
* Compress-route enum members per image mime-group, mirroring the shipped
|
|
@@ -232,3 +273,93 @@ export function isUnknownEnumValue(inputToken, optionKey, value) {
|
|
|
232
273
|
return false;
|
|
233
274
|
return !(typeof value === 'string' && members.includes(value));
|
|
234
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* Contract `depends_on` per compress-image output option, mirroring
|
|
278
|
+
* `availability.json` `operations.compress.mime_groups.<group>.options.<opt>.depends_on`
|
|
279
|
+
* (ehHU08Hu). The rule is option-consistent across every image group that carries
|
|
280
|
+
* the option, so this is a FLAT table (validated group-by-group by
|
|
281
|
+
* `output-route-conformance.test.ts` / PHP `ImageOutputRouteConformanceTest`).
|
|
282
|
+
*
|
|
283
|
+
* Kept as a hand table — NOT a runtime read of the ~238KB availability sidecar —
|
|
284
|
+
* so the gate stays browser-safe with no contracts-version coupling, exactly like
|
|
285
|
+
* {@link COMPRESS_OPTION_VALUES}. Mirrored by PHP
|
|
286
|
+
* `ImageOutputRoutes::OUTPUT_OPTION_DEPENDS_ON`.
|
|
287
|
+
*
|
|
288
|
+
* Generalises the 86gAu5Tr auto_quality gate: every option's dependency is
|
|
289
|
+
* checked uniformly, so quality/lossless/target_size_bytes under `auto_quality`,
|
|
290
|
+
* `target_size_bytes` without `target_size`, `fit` without width/height, etc. are
|
|
291
|
+
* all rejected pre-upload instead of only the one hand-coded case.
|
|
292
|
+
*/
|
|
293
|
+
export const OUTPUT_OPTION_DEPENDS_ON = {
|
|
294
|
+
quality: { requiresKey: 'encoding_mode', requiresValue: 'quality' },
|
|
295
|
+
lossless: { requiresKey: 'encoding_mode', requiresValue: 'quality' },
|
|
296
|
+
quality_preset: { requiresKey: 'encoding_mode', requiresValue: 'auto_quality' },
|
|
297
|
+
target_size_bytes: { requiresKey: 'encoding_mode', requiresValue: 'target_size' },
|
|
298
|
+
fit: { requiresAnyOf: ['width', 'height'] },
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Default of each depended-on key — an ABSENT key resolves to this before the
|
|
302
|
+
* dependency check (the server applies the same default). `encoding_mode`
|
|
303
|
+
* defaults to `quality`, so `quality`/`lossless` are valid with no explicit mode,
|
|
304
|
+
* but `target_size_bytes` / `quality_preset` are not. Pinned to `availability.json`
|
|
305
|
+
* defaults by the conformance suite.
|
|
306
|
+
*/
|
|
307
|
+
export const DEPENDS_ON_KEY_DEFAULTS = {
|
|
308
|
+
encoding_mode: 'quality',
|
|
309
|
+
};
|
|
310
|
+
/**
|
|
311
|
+
* The first contract `depends_on` an already-lowered compress-image wire-option
|
|
312
|
+
* set violates for the resolved `route`, or `undefined` when every dependency is
|
|
313
|
+
* satisfied (ehHU08Hu). The caller ({@link Recipe} output lowering) throws
|
|
314
|
+
* `invalid_option_combination` with the returned message + conflictingFields.
|
|
315
|
+
* Only options PRESENT in `wireOptions` are checked; a scalar dependency reads
|
|
316
|
+
* the depended-on key's effective value ({@link DEPENDS_ON_KEY_DEFAULTS} when
|
|
317
|
+
* absent). A scalar (encoding_mode) dependency is skipped on a `format_change`
|
|
318
|
+
* (convert has its own deps); universal deps (e.g. `fit → width|height`, identical
|
|
319
|
+
* in compress + convert) run on BOTH routes. Mirrored by PHP
|
|
320
|
+
* `ImageOutputRoutes::dependsOnViolation`.
|
|
321
|
+
*/
|
|
322
|
+
export function dependsOnViolation(wireOptions, route) {
|
|
323
|
+
for (const [option, rule] of Object.entries(OUTPUT_OPTION_DEPENDS_ON)) {
|
|
324
|
+
// A nullish value is NOT "set" — the contract `set` condition needs a real
|
|
325
|
+
// value, and PHP drops null options before lowering, so treat null == absent
|
|
326
|
+
// for parity (codex: `{ fit: 'max', width: null }` must reject, not bypass).
|
|
327
|
+
if (wireOptions[option] == null)
|
|
328
|
+
continue;
|
|
329
|
+
if ('requiresAnyOf' in rule) {
|
|
330
|
+
if (!rule.requiresAnyOf.some((key) => wireOptions[key] != null)) {
|
|
331
|
+
return {
|
|
332
|
+
conflictingFields: [option, ...rule.requiresAnyOf],
|
|
333
|
+
message: `output(): '${option}' requires at least one of ${rule.requiresAnyOf.join(', ')} to be set ` +
|
|
334
|
+
`(its contract dependency). Set ${rule.requiresAnyOf.join(' or ')}, or drop '${option}'.`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
// Scalar deps in this (compress-image) table are all on `encoding_mode`, a
|
|
340
|
+
// same_format optimiser key — validate them on same_format ONLY. The
|
|
341
|
+
// universal requiresAnyOf dep (fit → width|height) above runs on BOTH routes.
|
|
342
|
+
//
|
|
343
|
+
// A format_change routes via `convert`, which has no encoding_mode and carries
|
|
344
|
+
// its own deps — but those need NO table here (L2Ay7Uak, resolved as a no-op).
|
|
345
|
+
// Every convert image dep is keyed on `output_format`, and the per-target
|
|
346
|
+
// `honored` set the lowering already enforces IS that constraint materialised:
|
|
347
|
+
// `output('gif', { quality: 80 })` is rejected by the honored gate, with a
|
|
348
|
+
// better message, before this function runs. That equivalence is PINNED by
|
|
349
|
+
// `output-route-conformance.test.ts` (+ the PHP mirror), which fails closed if
|
|
350
|
+
// convert ever gains a dep keyed on something other than output_format — which
|
|
351
|
+
// is the case that would genuinely need a gate here.
|
|
352
|
+
if (route !== 'same_format')
|
|
353
|
+
continue;
|
|
354
|
+
const effective = wireOptions[rule.requiresKey] ?? DEPENDS_ON_KEY_DEFAULTS[rule.requiresKey];
|
|
355
|
+
if (effective !== rule.requiresValue) {
|
|
356
|
+
return {
|
|
357
|
+
conflictingFields: [rule.requiresKey, option],
|
|
358
|
+
message: `output(): '${option}' requires ${rule.requiresKey} '${rule.requiresValue}' (its contract ` +
|
|
359
|
+
`dependency), but ${rule.requiresKey} is '${String(effective)}'. Set ${rule.requiresKey}: ` +
|
|
360
|
+
`'${rule.requiresValue}', or drop '${option}'.`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
@@ -137,6 +137,13 @@ export interface WatermarkOptions {
|
|
|
137
137
|
* overlays on one base image (z-order = array index). MUTUALLY EXCLUSIVE with
|
|
138
138
|
* the flat single-overlay options above; the server rejects mixing the two as
|
|
139
139
|
* `invalid_options`. image_watermark jpeg/png/webp bases only.
|
|
140
|
+
*
|
|
141
|
+
* NOTE: NOT usable via `watermark()` yet — the facade composites a single
|
|
142
|
+
* overlay (the positional `overlay`, wire source src_1), so `overlays[]` would
|
|
143
|
+
* reference sources it cannot create. `watermark()` rejects it at lowering
|
|
144
|
+
* (`overlays_unsupported`); use the flat single-overlay options above instead.
|
|
145
|
+
* Kept as a valid contract wire key — multi-overlay stacking is a future
|
|
146
|
+
* feature (Vbbdq9C4).
|
|
140
147
|
*/
|
|
141
148
|
overlays?: WatermarkOverlay[];
|
|
142
149
|
}
|
|
@@ -154,9 +161,11 @@ export type OutputMetadata = 'strip' | 'keep';
|
|
|
154
161
|
* Compression mode on the optimiser (same_format) route (contract `encoding_mode`
|
|
155
162
|
* enum). `quality` (default) drives the encode by the quality slider; `target_size`
|
|
156
163
|
* targets a byte budget via the worker's encode-measure loop — STABLE since
|
|
157
|
-
* contracts v2.108.0 (jpeg/webp/avif).
|
|
164
|
+
* contracts v2.108.0 (jpeg/webp/avif). `auto_quality` lets the worker pick the
|
|
165
|
+
* quality from a named `quality_preset` (its `depends_on`) — the output lowering
|
|
166
|
+
* infers it for you when you set `quality_preset` without an `encoding_mode`.
|
|
158
167
|
*/
|
|
159
|
-
export type OutputEncodingMode = 'quality' | 'target_size';
|
|
168
|
+
export type OutputEncodingMode = 'quality' | 'target_size' | 'auto_quality';
|
|
160
169
|
/** Chroma subsampling for JPEG output (contract `chroma_subsampling` enum, v2.110.0). `420` smallest → `444` highest fidelity. Honored: same_format jpeg only. */
|
|
161
170
|
export type OutputChromaSubsampling = '420' | '422' | '444';
|
|
162
171
|
/** ICC colour-profile handling (contract `color_profile` enum, v2.112.0). `keep` preserves the embedded profile; `srgb` converts to sRGB; `strip` removes it. Route/value availability is gated by the output lowering. */
|
|
@@ -76,6 +76,28 @@ export interface ResolveCompressOptionsOutput {
|
|
|
76
76
|
*/
|
|
77
77
|
export declare function _parseTargetSize(value: unknown): number;
|
|
78
78
|
export declare const KNOWN_WIRE_FIELDS: Readonly<Record<PresetMedia, ReadonlySet<string>>>;
|
|
79
|
+
/**
|
|
80
|
+
* Compress options that are `availability: planned` per mime-group, mirroring the
|
|
81
|
+
* shipped `availability/availability.json`
|
|
82
|
+
* `operations.compress.mime_groups.<group>.options.<opt>.availability`.
|
|
83
|
+
*
|
|
84
|
+
* Kept as a hand table (NOT a runtime read of the ~238KB availability sidecar) for
|
|
85
|
+
* the same reasons as {@link IMAGE_OUTPUT_ROUTES}: the gate stays browser-safe, and
|
|
86
|
+
* — decisively — it has NO dependency on which `@giveitsmaller/contracts` version a
|
|
87
|
+
* consumer resolved. A generated-metadata read would FAIL OPEN on an older published
|
|
88
|
+
* contracts (the rtkzl9gr failure mode), and fail-open is the wrong direction for a
|
|
89
|
+
* gate whose entire job is to fail closed.
|
|
90
|
+
*
|
|
91
|
+
* PINNED to `availability.json` by `tests/unit/preset-planned-conformance.test.ts`,
|
|
92
|
+
* which fails closed in BOTH directions — a contract regen that marks a new option
|
|
93
|
+
* `planned`, or unmarks one, breaks the build rather than the caller. Mirrored by PHP
|
|
94
|
+
* `PresetResolver::PLANNED_COMPRESS_OPTIONS`.
|
|
95
|
+
*
|
|
96
|
+
* `video.speed` is listed for a faithful projection even though no shipped preset
|
|
97
|
+
* cell emits it; the conformance test pins the whole projection, not just the keys
|
|
98
|
+
* we happen to use today.
|
|
99
|
+
*/
|
|
100
|
+
export declare const PLANNED_COMPRESS_OPTIONS: Readonly<Record<PresetMedia, ReadonlySet<string>>>;
|
|
79
101
|
/**
|
|
80
102
|
* Resolve the wire payload + introspection projection for a compress
|
|
81
103
|
* operation call. Throws {@link GislConfigError} before any network
|
|
@@ -251,6 +251,20 @@ const MEDIA_FIELDS = Object.freeze({
|
|
|
251
251
|
document_odf: new Set(['stripMetadata', 'stripUnusedStyles']),
|
|
252
252
|
document_epub: new Set(['fontSubsetting', 'stripUnusedCss']),
|
|
253
253
|
});
|
|
254
|
+
const OUT = (key) => ({ verb: 'output', key });
|
|
255
|
+
const CROSS_VERB_OVERRIDES = Object.freeze({
|
|
256
|
+
image: Object.freeze({
|
|
257
|
+
width: OUT('width'), height: OUT('height'), fit: OUT('fit'),
|
|
258
|
+
autoOrient: OUT('auto_orient'), colorProfile: OUT('color_profile'),
|
|
259
|
+
progressive: OUT('progressive'), lossless: OUT('lossless'),
|
|
260
|
+
qualityPreset: OUT('quality_preset'), encodingMode: OUT('encoding_mode'),
|
|
261
|
+
targetSizeBytes: OUT('target_size_bytes'), chromaSubsampling: OUT('chroma_subsampling'),
|
|
262
|
+
optimizationLevel: OUT('optimization_level'), avifSpeed: OUT('avif_speed'),
|
|
263
|
+
}),
|
|
264
|
+
// convert() takes the target format positionally, not as an option.
|
|
265
|
+
audio: Object.freeze({ outputFormat: { verb: 'convert', key: null } }),
|
|
266
|
+
video: Object.freeze({ outputFormat: { verb: 'convert', key: null } }),
|
|
267
|
+
});
|
|
254
268
|
function detectMismatchedOverrides(media, overrides) {
|
|
255
269
|
const expected = MEDIA_FIELDS[media];
|
|
256
270
|
const keys = Object.keys(overrides);
|
|
@@ -264,6 +278,37 @@ function detectMismatchedOverrides(media, overrides) {
|
|
|
264
278
|
const unknownFields = keys.filter((k) => !expected.has(k));
|
|
265
279
|
if (unknownFields.length === 0)
|
|
266
280
|
return;
|
|
281
|
+
// Options that are real for THIS media but belong to another verb are
|
|
282
|
+
// answered with that verb, before the other-media guess below — otherwise a
|
|
283
|
+
// key both this media and another one recognises (image `width` vs video
|
|
284
|
+
// `width`) gets blamed on the wrong media.
|
|
285
|
+
// Classified PER FIELD, not all-or-nothing: `{ width, codec }` on an image
|
|
286
|
+
// must still tell the caller that `width` is a legal resize on output(),
|
|
287
|
+
// rather than reverting to "you passed video options" for the pair.
|
|
288
|
+
const crossVerb = CROSS_VERB_OVERRIDES[media];
|
|
289
|
+
const crossVerbFields = crossVerb === undefined ? [] : unknownFields.filter((k) => crossVerb[k] !== undefined);
|
|
290
|
+
if (crossVerbFields.length > 0 && crossVerb !== undefined) {
|
|
291
|
+
const strays = unknownFields.filter((k) => crossVerb[k] === undefined);
|
|
292
|
+
const byVerb = new Map();
|
|
293
|
+
for (const k of crossVerbFields) {
|
|
294
|
+
const target = crossVerb[k];
|
|
295
|
+
const list = byVerb.get(target.verb) ?? [];
|
|
296
|
+
list.push(target.key ?? k);
|
|
297
|
+
byVerb.set(target.verb, list);
|
|
298
|
+
}
|
|
299
|
+
const clauses = [...byVerb.entries()].map(([verb, keys]) => verb === 'convert'
|
|
300
|
+
? `${keys.join(', ')} is the format argument of convert()`
|
|
301
|
+
: `${keys.join(', ')} ${keys.length === 1 ? 'is an option' : 'are options'} on ${verb}()`);
|
|
302
|
+
const outputKeys = byVerb.get('output');
|
|
303
|
+
throw new GislConfigError(`presetOverrides for '${media}' contained ${crossVerbFields.join(', ')}, which ${crossVerbFields.length === 1 ? 'does' : 'do'} not belong on the compress preset surface: ${clauses.join('; ')}.` +
|
|
304
|
+
(strays.length > 0 ? ` Also unrecognised for '${media}': ${strays.join(', ')}.` : ''), {
|
|
305
|
+
reason: 'type_mismatch',
|
|
306
|
+
conflictingFields: unknownFields,
|
|
307
|
+
suggestion: outputKeys !== undefined
|
|
308
|
+
? `Move ${outputKeys.join(', ')} to output(): .output(format, { ${outputKeys[0]}: … }).`
|
|
309
|
+
: 'Use convert(format) to change the output format.',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
267
312
|
// Look up which OTHER media owns every unknown field — if a single
|
|
268
313
|
// OTHER media's field set covers them all, that's a type_mismatch.
|
|
269
314
|
for (const otherMedia of Object.keys(MEDIA_FIELDS)) {
|
|
@@ -319,6 +364,35 @@ export const KNOWN_WIRE_FIELDS = Object.freeze({
|
|
|
319
364
|
document_odf: new Set(['strip_metadata', 'strip_unused_styles']),
|
|
320
365
|
document_epub: new Set(['font_subsetting', 'strip_unused_css']),
|
|
321
366
|
});
|
|
367
|
+
/**
|
|
368
|
+
* Compress options that are `availability: planned` per mime-group, mirroring the
|
|
369
|
+
* shipped `availability/availability.json`
|
|
370
|
+
* `operations.compress.mime_groups.<group>.options.<opt>.availability`.
|
|
371
|
+
*
|
|
372
|
+
* Kept as a hand table (NOT a runtime read of the ~238KB availability sidecar) for
|
|
373
|
+
* the same reasons as {@link IMAGE_OUTPUT_ROUTES}: the gate stays browser-safe, and
|
|
374
|
+
* — decisively — it has NO dependency on which `@giveitsmaller/contracts` version a
|
|
375
|
+
* consumer resolved. A generated-metadata read would FAIL OPEN on an older published
|
|
376
|
+
* contracts (the rtkzl9gr failure mode), and fail-open is the wrong direction for a
|
|
377
|
+
* gate whose entire job is to fail closed.
|
|
378
|
+
*
|
|
379
|
+
* PINNED to `availability.json` by `tests/unit/preset-planned-conformance.test.ts`,
|
|
380
|
+
* which fails closed in BOTH directions — a contract regen that marks a new option
|
|
381
|
+
* `planned`, or unmarks one, breaks the build rather than the caller. Mirrored by PHP
|
|
382
|
+
* `PresetResolver::PLANNED_COMPRESS_OPTIONS`.
|
|
383
|
+
*
|
|
384
|
+
* `video.speed` is listed for a faithful projection even though no shipped preset
|
|
385
|
+
* cell emits it; the conformance test pins the whole projection, not just the keys
|
|
386
|
+
* we happen to use today.
|
|
387
|
+
*/
|
|
388
|
+
export const PLANNED_COMPRESS_OPTIONS = Object.freeze({
|
|
389
|
+
image: new Set([]),
|
|
390
|
+
audio: new Set([]),
|
|
391
|
+
video: new Set(['speed']),
|
|
392
|
+
document_office: new Set(['strip_hidden_data', 'strip_macros', 'strip_unused_fonts']),
|
|
393
|
+
document_odf: new Set(['strip_metadata', 'strip_unused_styles']),
|
|
394
|
+
document_epub: new Set(['font_subsetting', 'strip_unused_css']),
|
|
395
|
+
});
|
|
322
396
|
function validateMerged(media, merged, explicitKeys, winners) {
|
|
323
397
|
// Unknown-field defence-in-depth: every key must belong to the
|
|
324
398
|
// media's wire surface OR be one of the resolver-derived wire keys
|
|
@@ -527,6 +601,26 @@ export function resolveCompressOptions(input) {
|
|
|
527
601
|
delete acc.merged.bitrate;
|
|
528
602
|
acc.winners.delete('bitrate');
|
|
529
603
|
}
|
|
604
|
+
// A shipped preset must never put an `availability: planned` option on the wire.
|
|
605
|
+
// The API rejects a planned option when the KEY IS PRESENT and ignores it when
|
|
606
|
+
// absent (`CreateWorkflowCommandHandler::recordPlannedFromMap` — a materialized
|
|
607
|
+
// default deliberately does NOT trigger it), so a value WE synthesized becomes a
|
|
608
|
+
// 422 `feature_not_available` at create that the caller never asked for. That is
|
|
609
|
+
// what broke every document compress with an `optimizeFor` in 0.21.0 (5Eksm9s7).
|
|
610
|
+
//
|
|
611
|
+
// Drop ONLY the sdkDefault-sourced ones. A planned option from any other layer is
|
|
612
|
+
// a caller choice — `clientDefault` and `scopedDefault` are user-registered via
|
|
613
|
+
// `gisl.create({ presetDefaults })` / `client.withPresetDefaults(...)`, not baked
|
|
614
|
+
// in by us — and is left in place for the server to refuse honestly. Same rule and
|
|
615
|
+
// same reason as the lossless-bitrate drop above: never silently swallow a key the
|
|
616
|
+
// caller chose. Do NOT "simplify" either of these into an always-drop; a silent
|
|
617
|
+
// no-op on an explicit request is worse than an honest 422.
|
|
618
|
+
for (const key of PLANNED_COMPRESS_OPTIONS[media]) {
|
|
619
|
+
if (acc.winners.get(key) === 'sdkDefault') {
|
|
620
|
+
delete acc.merged[key];
|
|
621
|
+
acc.winners.delete(key);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
530
624
|
// 7. Validate the merged payload (post-merge — catches cross-layer
|
|
531
625
|
// disagreements). May throw GislConfigError with resolvedSnapshot.
|
|
532
626
|
const explicitWireKeys = new Set();
|
|
@@ -12,10 +12,22 @@
|
|
|
12
12
|
// `mode` + `iccProfile` were REMOVED — the worker is lossy-only and always
|
|
13
13
|
// strips metadata, so advertising a lossless mode or ICC-profile policy was
|
|
14
14
|
// an over-claim. `progressive` is still a per-JPEG wire option but is no
|
|
15
|
-
// longer carried in the preset cell.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// longer carried in the preset cell.
|
|
16
|
+
//
|
|
17
|
+
// `width`/`height`/`fit`/`autoOrient` were removed earlier on the grounds that
|
|
18
|
+
// "the image-compress worker never resized". CAREFUL — that is still true of
|
|
19
|
+
// the Rust optimiser crate and NOT true end-to-end. Since contract v2.97.0
|
|
20
|
+
// ("resize lives inside Output") the API canonicalises an image compress
|
|
21
|
+
// carrying width/height/fit into a `convert` op, and convert IS the resize
|
|
22
|
+
// engine, so such a request returns a genuinely resized file. Their absence
|
|
23
|
+
// here is therefore a SURFACE choice, not a capability limit: resize is
|
|
24
|
+
// expressed via `output()` (see `OutputOptions`), which the compress
|
|
25
|
+
// conformance gate records as CROSS_VERB_ROUTING and self-verifies. Reading
|
|
26
|
+
// this comment as "unsupported" is what produced cySAEZHR. Whether compress()
|
|
27
|
+
// should ALSO carry them is an open ergonomic-expansion decision, not a bug.
|
|
28
|
+
//
|
|
29
|
+
// Per-call knobs are deliberately excluded — they belong on the per-call
|
|
30
|
+
// argument shape.
|
|
19
31
|
import { shippedDefaultsFor as f3ShippedDefaultsFor } from '../../generated/sdk_spec/presets.js';
|
|
20
32
|
import { translateEnum } from './_translate.js';
|
|
21
33
|
export class ImageCompressPresetOptions {
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { VideoCodec, VideoPreset, VideoFit, AudioCodec, AudioBitrate, OptimizeFor } from '../../generated/sdk_spec/enums.js';
|
|
2
2
|
export interface VideoCompressPresetOptionsInput {
|
|
3
3
|
readonly codec?: VideoCodec;
|
|
4
|
+
/**
|
|
5
|
+
* Target output size (`'50MB'`-style string — BINARY units, 1 KB = 1024 — or a byte
|
|
6
|
+
* count). Derived by the resolver into `target_size_bytes` + `encoding_mode:
|
|
7
|
+
* 'target_size'`.
|
|
8
|
+
*
|
|
9
|
+
* **NOT AVAILABLE FOR LONG INPUTS.** A compress whose input duration routes to the
|
|
10
|
+
* long-form path rejects it (`reject_long_form_target_size`): that path is
|
|
11
|
+
* single-pass-CRF by construction and two-pass target-size is unbuilt. The request
|
|
12
|
+
* fails during execution, and the SDK cannot warn earlier — routing is decided
|
|
13
|
+
* server-side at create-plan time, so there is nothing here to check it against.
|
|
14
|
+
* Short-form compresses honour it normally. Tracked by `zJN6XIi5`, blocked on a
|
|
15
|
+
* contract that can express per-execution-path availability. The same limit applies
|
|
16
|
+
* to {@link MergeOptions.targetSize}.
|
|
17
|
+
*/
|
|
4
18
|
readonly targetSize?: string | number;
|
|
5
19
|
readonly crf?: number;
|
|
6
20
|
readonly preset?: VideoPreset;
|
package/dist/errors.d.ts
CHANGED
|
@@ -159,6 +159,21 @@ export declare class GislBalanceExhaustedError extends GislApiError {
|
|
|
159
159
|
export declare class GislLongFormConcurrencyError extends GislApiError {
|
|
160
160
|
readonly payload: LongFormConcurrencyLimitResponse;
|
|
161
161
|
constructor(statusCode: number, errorMessage: string, payload: LongFormConcurrencyLimitResponse, path?: string, extra?: Omit<GislApiErrorOptions, 'payload'>);
|
|
162
|
+
/**
|
|
163
|
+
* ALWAYS `false`, overriding the base 429-implies-retryable heuristic
|
|
164
|
+
* (UO1xYecu). This 429 is not a rate limit: it carries no `Retry-After` and
|
|
165
|
+
* clears only when an in-flight long-form workflow finishes, so a back-off
|
|
166
|
+
* retries into a wall that no amount of waiting-then-retrying opens. The base
|
|
167
|
+
* accessor reported `true` purely from the status, contradicting this class's
|
|
168
|
+
* own documented handling ("wait on completion or upgrade — do NOT back off")
|
|
169
|
+
* and instructing the one recovery that cannot work.
|
|
170
|
+
*
|
|
171
|
+
* Overridden per-class rather than via a code table because this is the only
|
|
172
|
+
* such code today; the general fix — an explicit taxonomy verdict outranking
|
|
173
|
+
* the status heuristic — arrives with the `error-taxonomy.yaml` `retryable`
|
|
174
|
+
* enum (contracts `plwcAqBr`), tracked on UO1xYecu.
|
|
175
|
+
*/
|
|
176
|
+
get retryable(): boolean;
|
|
162
177
|
/** The pricing / upgrade deep link (`links.upgrade`), or `undefined` when absent. */
|
|
163
178
|
get upgradeUrl(): string | undefined;
|
|
164
179
|
}
|
|
@@ -459,6 +474,44 @@ export declare class GislTimeoutError extends GislError {
|
|
|
459
474
|
readonly workflowId?: string;
|
|
460
475
|
constructor(message: string, workflowId?: string);
|
|
461
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* A `mapEach` fan-out timed out mid-batch — the deadline elapsed either while a
|
|
479
|
+
* child was still running (the common case) or cleanly between child runs. The
|
|
480
|
+
* parent and some children have ALREADY completed, so re-running the whole batch
|
|
481
|
+
* re-does finished work. This carries their ids so the caller can poll them (via
|
|
482
|
+
* `client.getWorkflowStatus` / `getWorkflowDownloads`) to recover the finished
|
|
483
|
+
* work and re-run ONLY the children that were never created.
|
|
484
|
+
*
|
|
485
|
+
* Subclasses {@link GislTimeoutError}, so an existing
|
|
486
|
+
* `catch (e) { if (e instanceof GislTimeoutError) … }` still catches it. The
|
|
487
|
+
* inherited `workflowId` carries the IN-FLIGHT child — the one that was running
|
|
488
|
+
* when the deadline elapsed (a child's own timeout, the common path) — or stays
|
|
489
|
+
* `undefined` when the deadline elapsed cleanly BETWEEN children (no in-flight
|
|
490
|
+
* child). To recover, poll `workflowId` (if set) + {@link parentWorkflowId} +
|
|
491
|
+
* {@link completedWorkflowIds}, then re-run only the children that never started.
|
|
492
|
+
*
|
|
493
|
+
* NOTE on double-charge: the server-side create-dedupe (DSxwCetg) is what
|
|
494
|
+
* prevents a byte-identical child re-create from settling a SECOND charge within
|
|
495
|
+
* the dedup window; this error's job is efficient RECOVERY (skip the completed
|
|
496
|
+
* work) + defense-in-depth, not the sole charge guard.
|
|
497
|
+
*/
|
|
498
|
+
export declare class GislFanOutTimeoutError extends GislTimeoutError {
|
|
499
|
+
/** The child workflows that completed before the deadline elapsed. */
|
|
500
|
+
readonly completedWorkflowIds: readonly string[];
|
|
501
|
+
/** The parent workflow, which ran to completion before the fan-out began. */
|
|
502
|
+
readonly parentWorkflowId?: string;
|
|
503
|
+
constructor(message: string, opts: {
|
|
504
|
+
completedWorkflowIds: readonly string[];
|
|
505
|
+
parentWorkflowId?: string;
|
|
506
|
+
/**
|
|
507
|
+
* The in-flight child that timed out mid-run (its own deadline elapsed);
|
|
508
|
+
* `undefined` for a clean between-children timeout with no child running.
|
|
509
|
+
*/
|
|
510
|
+
workflowId?: string;
|
|
511
|
+
/** The underlying child {@link GislTimeoutError}, preserved for chaining. */
|
|
512
|
+
cause?: unknown;
|
|
513
|
+
});
|
|
514
|
+
}
|
|
462
515
|
/**
|
|
463
516
|
* Transport-level failure: the underlying `fetch` (or other transport) could
|
|
464
517
|
* not produce a usable response — DNS, TCP, TLS, a mid-stream disconnect, or a
|
package/dist/errors.js
CHANGED
|
@@ -172,6 +172,23 @@ export class GislLongFormConcurrencyError extends GislApiError {
|
|
|
172
172
|
super(statusCode, errorMessage, path, undefined, buildOptionsWithPayload(payload, extra));
|
|
173
173
|
this.name = 'GislLongFormConcurrencyError';
|
|
174
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* ALWAYS `false`, overriding the base 429-implies-retryable heuristic
|
|
177
|
+
* (UO1xYecu). This 429 is not a rate limit: it carries no `Retry-After` and
|
|
178
|
+
* clears only when an in-flight long-form workflow finishes, so a back-off
|
|
179
|
+
* retries into a wall that no amount of waiting-then-retrying opens. The base
|
|
180
|
+
* accessor reported `true` purely from the status, contradicting this class's
|
|
181
|
+
* own documented handling ("wait on completion or upgrade — do NOT back off")
|
|
182
|
+
* and instructing the one recovery that cannot work.
|
|
183
|
+
*
|
|
184
|
+
* Overridden per-class rather than via a code table because this is the only
|
|
185
|
+
* such code today; the general fix — an explicit taxonomy verdict outranking
|
|
186
|
+
* the status heuristic — arrives with the `error-taxonomy.yaml` `retryable`
|
|
187
|
+
* enum (contracts `plwcAqBr`), tracked on UO1xYecu.
|
|
188
|
+
*/
|
|
189
|
+
get retryable() {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
175
192
|
/** The pricing / upgrade deep link (`links.upgrade`), or `undefined` when absent. */
|
|
176
193
|
get upgradeUrl() {
|
|
177
194
|
return this.payload.links?.upgrade;
|
|
@@ -505,6 +522,43 @@ export class GislTimeoutError extends GislError {
|
|
|
505
522
|
this.workflowId = workflowId === '' ? undefined : workflowId;
|
|
506
523
|
}
|
|
507
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* A `mapEach` fan-out timed out mid-batch — the deadline elapsed either while a
|
|
527
|
+
* child was still running (the common case) or cleanly between child runs. The
|
|
528
|
+
* parent and some children have ALREADY completed, so re-running the whole batch
|
|
529
|
+
* re-does finished work. This carries their ids so the caller can poll them (via
|
|
530
|
+
* `client.getWorkflowStatus` / `getWorkflowDownloads`) to recover the finished
|
|
531
|
+
* work and re-run ONLY the children that were never created.
|
|
532
|
+
*
|
|
533
|
+
* Subclasses {@link GislTimeoutError}, so an existing
|
|
534
|
+
* `catch (e) { if (e instanceof GislTimeoutError) … }` still catches it. The
|
|
535
|
+
* inherited `workflowId` carries the IN-FLIGHT child — the one that was running
|
|
536
|
+
* when the deadline elapsed (a child's own timeout, the common path) — or stays
|
|
537
|
+
* `undefined` when the deadline elapsed cleanly BETWEEN children (no in-flight
|
|
538
|
+
* child). To recover, poll `workflowId` (if set) + {@link parentWorkflowId} +
|
|
539
|
+
* {@link completedWorkflowIds}, then re-run only the children that never started.
|
|
540
|
+
*
|
|
541
|
+
* NOTE on double-charge: the server-side create-dedupe (DSxwCetg) is what
|
|
542
|
+
* prevents a byte-identical child re-create from settling a SECOND charge within
|
|
543
|
+
* the dedup window; this error's job is efficient RECOVERY (skip the completed
|
|
544
|
+
* work) + defense-in-depth, not the sole charge guard.
|
|
545
|
+
*/
|
|
546
|
+
export class GislFanOutTimeoutError extends GislTimeoutError {
|
|
547
|
+
/** The child workflows that completed before the deadline elapsed. */
|
|
548
|
+
completedWorkflowIds;
|
|
549
|
+
/** The parent workflow, which ran to completion before the fan-out began. */
|
|
550
|
+
parentWorkflowId;
|
|
551
|
+
constructor(message, opts) {
|
|
552
|
+
// The inherited workflowId is the in-flight child (or undefined between children).
|
|
553
|
+
super(message, opts.workflowId);
|
|
554
|
+
this.name = 'GislFanOutTimeoutError';
|
|
555
|
+
this.completedWorkflowIds = [...opts.completedWorkflowIds];
|
|
556
|
+
this.parentWorkflowId = opts.parentWorkflowId === '' ? undefined : opts.parentWorkflowId;
|
|
557
|
+
if (opts.cause !== undefined) {
|
|
558
|
+
this.cause = opts.cause;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
508
562
|
/**
|
|
509
563
|
* Transport-level failure: the underlying `fetch` (or other transport) could
|
|
510
564
|
* not produce a usable response — DNS, TCP, TLS, a mid-stream disconnect, or a
|
package/dist/file-first.js
CHANGED
|
@@ -14,7 +14,7 @@ import { _detectCompressMedia, _detectAudioLossless, _consumeSseToTerminal, _pol
|
|
|
14
14
|
import { LazyHttpDownloader } from './lazy-downloader.js';
|
|
15
15
|
import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
|
|
16
16
|
import { validateVerbOptions, assertThumbnailDimensions } from './ergonomic/option_validation.js';
|
|
17
|
-
import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, isUnknownEnumValue, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
|
|
17
|
+
import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, isUnknownEnumValue, dependsOnViolation, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
|
|
18
18
|
import { OptimizeFor } from './generated/sdk_spec/enums.js';
|
|
19
19
|
import { uploadSource, jobOutputSource } from './types.js';
|
|
20
20
|
// Value import used only at call-time (inside MergedRecipe.toWorkflowPayload),
|
|
@@ -1101,7 +1101,7 @@ export class Recipe {
|
|
|
1101
1101
|
if (requested !== undefined && FACADE_MANAGED_OUTPUTS.includes(requested)) {
|
|
1102
1102
|
const facade = { output_format: requested };
|
|
1103
1103
|
for (const [key, value] of Object.entries(step.options)) {
|
|
1104
|
-
if (key === 'output_format' || value === undefined)
|
|
1104
|
+
if (key === 'output_format' || value === undefined || value === null)
|
|
1105
1105
|
continue;
|
|
1106
1106
|
if (key !== 'quality') {
|
|
1107
1107
|
throw new GislConfigError(`output(): '${key}' needs a detectable input format to route; reference the file by ` +
|
|
@@ -1122,7 +1122,9 @@ export class Recipe {
|
|
|
1122
1122
|
}
|
|
1123
1123
|
const wireOptions = { output_format: resolved.outputFormatWire };
|
|
1124
1124
|
for (const [key, value] of Object.entries(step.options)) {
|
|
1125
|
-
|
|
1125
|
+
// Drop a null value (as PHP does) so a null option never reaches the wire
|
|
1126
|
+
// and is treated as absent by the depends_on gate — full null parity (codex).
|
|
1127
|
+
if (key === 'output_format' || value === undefined || value === null)
|
|
1126
1128
|
continue;
|
|
1127
1129
|
if (resolved.planned.has(key)) {
|
|
1128
1130
|
throw new GislConfigError(`output(): '${key}' is advertised but not available yet on the ${resolved.route} route ` +
|
|
@@ -1147,6 +1149,28 @@ export class Recipe {
|
|
|
1147
1149
|
}
|
|
1148
1150
|
wireOptions[key] = value;
|
|
1149
1151
|
}
|
|
1152
|
+
// quality_preset's contract `depends_on: { encoding_mode: auto_quality }`
|
|
1153
|
+
// (86gAu5Tr) — infer the mode when the caller set NONE so the preset forms a
|
|
1154
|
+
// VALID request. SAME_FORMAT only: encoding_mode is a compress optimiser and
|
|
1155
|
+
// quality_preset isn't honored on a format_change. An explicitly-conflicting
|
|
1156
|
+
// mode is rejected by the general depends_on gate below.
|
|
1157
|
+
if (resolved.route === 'same_format' &&
|
|
1158
|
+
wireOptions.quality_preset !== undefined &&
|
|
1159
|
+
wireOptions.encoding_mode === undefined) {
|
|
1160
|
+
wireOptions.encoding_mode = 'auto_quality';
|
|
1161
|
+
}
|
|
1162
|
+
// General contract `depends_on` validation (ehHU08Hu), scoped per route: the
|
|
1163
|
+
// universal fit→width|height dep runs on BOTH routes (identical in compress +
|
|
1164
|
+
// convert); the encoding_mode-family deps run on same_format only. Subsumes
|
|
1165
|
+
// the 86gAu5Tr auto_quality gate plus target_size_bytes-without-target_size,
|
|
1166
|
+
// fit-without-width/height, and any future compress-image depends_on.
|
|
1167
|
+
const dependency = dependsOnViolation(wireOptions, resolved.route);
|
|
1168
|
+
if (dependency !== undefined) {
|
|
1169
|
+
throw new GislConfigError(dependency.message, {
|
|
1170
|
+
reason: 'invalid_option_combination',
|
|
1171
|
+
conflictingFields: [...dependency.conflictingFields],
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1150
1174
|
return { type: resolved.sourceOp, options: wireOptions };
|
|
1151
1175
|
}
|
|
1152
1176
|
/**
|
|
@@ -1466,9 +1490,22 @@ function _validateWatermarkOverlay(overlay) {
|
|
|
1466
1490
|
}
|
|
1467
1491
|
}
|
|
1468
1492
|
function _lowerWatermarkOp(wireOp, options) {
|
|
1469
|
-
//
|
|
1470
|
-
//
|
|
1471
|
-
// the `
|
|
1493
|
+
// `overlays[]` (the multi-overlay stack) is a live contract option but is NOT
|
|
1494
|
+
// reachable through watermark(): the facade composites exactly ONE overlay —
|
|
1495
|
+
// the positional `overlay` (wire source src_1) — so overlays[1..] reference
|
|
1496
|
+
// sources it cannot create, any entry is invalid on a non-image base, and the
|
|
1497
|
+
// contract's `minItems: 1` makes an empty array invalid too. Reject it here at
|
|
1498
|
+
// lowering (mutation-safe — reads the FINAL options, catching a post-watermark()
|
|
1499
|
+
// `opts.overlays = [...]`) and point callers at the single-overlay knobs.
|
|
1500
|
+
// Real multi-overlay stacking is a future feature (Vbbdq9C4).
|
|
1501
|
+
if (options.overlays !== undefined) {
|
|
1502
|
+
throw new GislConfigError("watermark(): 'overlays[]' (multi-overlay stacking) is not supported — watermark() composites a " +
|
|
1503
|
+
'single overlay (the positional overlay argument). Use the top-level anchor / opacity / margin_x / ' +
|
|
1504
|
+
'margin_y / overlay_width options to place it. Multi-overlay stacking is a future feature.', { reason: 'overlays_unsupported', conflictingFields: ['overlays'] });
|
|
1505
|
+
}
|
|
1506
|
+
// The remaining watermark options (anchor/opacity/margin_x/margin_y/
|
|
1507
|
+
// overlay_width) are already wire keys; empty options omit the `options` key
|
|
1508
|
+
// (byte-identical to PHP).
|
|
1472
1509
|
const wire = { ...options };
|
|
1473
1510
|
return Object.keys(wire).length === 0 ? { type: wireOp } : { type: wireOp, options: wire };
|
|
1474
1511
|
}
|
|
@@ -1489,6 +1526,14 @@ function _lowerWatermarkOp(wireOp, options) {
|
|
|
1489
1526
|
*/
|
|
1490
1527
|
async function _uploadInputsAndCreate(client, inputs, toPayload, opts) {
|
|
1491
1528
|
const { webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs, uploadsLabel, workflowLabel } = opts;
|
|
1529
|
+
// Preflight: lower the composed chains with placeholder ids so a route-invalid
|
|
1530
|
+
// option (or any lowering-time gate — overlays, sole_op split, route/enum) in
|
|
1531
|
+
// ANY input's chain — a watermark base/overlay, a merge/archive member — throws
|
|
1532
|
+
// BEFORE we spend a single upload byte. The multi-input analog of the
|
|
1533
|
+
// single-input Recipe.assertOperationsLowerable preflight (0azjb6Rg); mirrors
|
|
1534
|
+
// PHP. The placeholder ids never reach the wire — the payload is discarded
|
|
1535
|
+
// (T3ltXsou). toWorkflowPayload is pure, so re-lowering at create is cheap.
|
|
1536
|
+
toPayload(inputs.map((_, i) => `preflight_${i}`));
|
|
1492
1537
|
const fileIds = [];
|
|
1493
1538
|
// Track each freshly-uploaded input's probe-gate inputs (a pre-uploaded id
|
|
1494
1539
|
// carries no local mime/size, so it is excluded — never probed).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ErrorCode = "missing_credentials" | "feature_requires_auth" | "undeclared_asset" | "unused_asset" | "per_input_options_not_supported" | "chain_cardinality_mismatch" | "multipart_part_invalid" | "multipart_part_count_exceeded" | "timeout" | "aborted" | "validation_failed" | "validation_error" | "cyclic_workflow_edges" | "workflow_edge_references_unknown_job" | "reserved_job_id_pattern" | "cyclic_job_output_source_graph" | "auth_failed" | "feature_tier_restricted" | "tier_restriction" | "multipart_session_ownership" | "multipart_session_auth_required" | "multipart_session_not_found" | "upload_not_found" | "workflow_expired" | "balance_exhausted" | "feature_not_available" | "upload_size_exceeds_tier" | "upload_duration_exceeds_tier" | "probe_pending" | "requires_reencode" | "invalid_options" | "invalid_combination" | "missing_dependency" | "unsupported_value" | "type_mismatch" | "image_dimensions_too_large" | "upload_failed" | "workflow_failed";
|
|
1
|
+
export type ErrorCode = "missing_credentials" | "feature_requires_auth" | "undeclared_asset" | "unused_asset" | "per_input_options_not_supported" | "chain_cardinality_mismatch" | "multipart_part_invalid" | "multipart_part_count_exceeded" | "timeout" | "aborted" | "validation_failed" | "validation_error" | "cyclic_workflow_edges" | "workflow_edge_references_unknown_job" | "reserved_job_id_pattern" | "cyclic_job_output_source_graph" | "auth_failed" | "feature_tier_restricted" | "tier_restriction" | "multipart_session_ownership" | "multipart_session_auth_required" | "multipart_session_not_found" | "upload_not_found" | "workflow_expired" | "balance_exhausted" | "feature_not_available" | "upload_size_exceeds_tier" | "upload_duration_exceeds_tier" | "probe_pending" | "requires_reencode" | "invalid_options" | "invalid_combination" | "missing_dependency" | "unsupported_value" | "type_mismatch" | "image_dimensions_too_large" | "upload_failed" | "workflow_failed" | "long_form_concurrency_limit_exceeded" | "unprocessable_entity" | "email_same" | "config_error" | "bundle_already_archived" | "fan_out_timeout" | "no_such_key" | "result_not_ready" | "sink_error" | "item_failed";
|
|
2
2
|
export type ErrorCategory = 'api' | 'config' | 'network' | 'auth' | 'validation' | 'chain';
|
|
3
3
|
export type ErrorStatus = 'wired' | 'planned';
|
|
4
4
|
export interface ErrorEntry {
|
|
@@ -377,7 +377,7 @@ export const ERROR_CODES = Object.freeze({
|
|
|
377
377
|
status: "wired",
|
|
378
378
|
httpStatus: 422,
|
|
379
379
|
retryable: true,
|
|
380
|
-
sdkClass: "
|
|
380
|
+
sdkClass: "GislProbePendingError",
|
|
381
381
|
description: "422 on workflow create — upload probing not yet complete; retry after the upload finishes probing. Wire `error_type: \"probe_pending\"`.",
|
|
382
382
|
metadataSchema: Object.freeze({
|
|
383
383
|
"jobRef": "string",
|
|
@@ -506,6 +506,142 @@ export const ERROR_CODES = Object.freeze({
|
|
|
506
506
|
"jobErrors": "array",
|
|
507
507
|
}),
|
|
508
508
|
}),
|
|
509
|
+
"long_form_concurrency_limit_exceeded": Object.freeze({
|
|
510
|
+
code: "long_form_concurrency_limit_exceeded",
|
|
511
|
+
category: "api",
|
|
512
|
+
source: "ErrorEnvelope.error",
|
|
513
|
+
status: "wired",
|
|
514
|
+
httpStatus: 429,
|
|
515
|
+
retryable: false,
|
|
516
|
+
sdkClass: "GislLongFormConcurrencyError",
|
|
517
|
+
description: "429 — the caller's tier long-form concurrency allowance is exhausted. Wire value is UPPERCASE `LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED` (api.yaml:1995); this file's `code:` follows the taxonomy's lowercase convention. Dispatched on the machine `error` code, NOT on the status: a generic infra rate-limit 429 carries a different/absent code and falls through to base GislApiError where retryAfterSeconds applies. Do not widen this entry to the status.",
|
|
518
|
+
metadataSchema: Object.freeze({
|
|
519
|
+
"currentTier": "string",
|
|
520
|
+
"maxDurationSeconds": "number",
|
|
521
|
+
}),
|
|
522
|
+
}),
|
|
523
|
+
"unprocessable_entity": Object.freeze({
|
|
524
|
+
code: "unprocessable_entity",
|
|
525
|
+
category: "auth",
|
|
526
|
+
source: "error_type",
|
|
527
|
+
status: "wired",
|
|
528
|
+
httpStatus: 422,
|
|
529
|
+
retryable: false,
|
|
530
|
+
sdkClass: "GislAuthRejectionError",
|
|
531
|
+
description: "422 on an auth-side-effect endpoint (register / verify-email / api-keys) — the request was well-formed but rejected on domain grounds. Dispatched on MEMBERSHIP of AuthRejectionEnvelope.error_type, per ADR-0019.",
|
|
532
|
+
metadataSchema: Object.freeze({
|
|
533
|
+
"errorType": "string",
|
|
534
|
+
}),
|
|
535
|
+
}),
|
|
536
|
+
"email_same": Object.freeze({
|
|
537
|
+
code: "email_same",
|
|
538
|
+
category: "auth",
|
|
539
|
+
source: "error_type",
|
|
540
|
+
status: "wired",
|
|
541
|
+
httpStatus: 422,
|
|
542
|
+
retryable: false,
|
|
543
|
+
sdkClass: "GislAuthRejectionError",
|
|
544
|
+
description: "422 on profile PATCH — the submitted email matches the current one, so there is nothing to change. Same envelope and sdkClass as unprocessable_entity; separate row because the wire value differs.",
|
|
545
|
+
metadataSchema: Object.freeze({
|
|
546
|
+
"errorType": "string",
|
|
547
|
+
}),
|
|
548
|
+
}),
|
|
549
|
+
"config_error": Object.freeze({
|
|
550
|
+
code: "config_error",
|
|
551
|
+
category: "config",
|
|
552
|
+
source: "SDK_local",
|
|
553
|
+
status: "wired",
|
|
554
|
+
httpStatus: null,
|
|
555
|
+
retryable: false,
|
|
556
|
+
sdkClass: "GislConfigError",
|
|
557
|
+
description: "Client-side configuration rejected before any request — thrown directly (preset resolver, output() gates), not only as a base class. `reason` carries the discriminator: `unknown_field` and `type_mismatch` are values of THIS field and deliberately have no rows of their own.",
|
|
558
|
+
metadataSchema: Object.freeze({
|
|
559
|
+
"reason": "string",
|
|
560
|
+
"conflictingFields": "array",
|
|
561
|
+
"resolvedSnapshot": "object",
|
|
562
|
+
"suggestion": "string",
|
|
563
|
+
}),
|
|
564
|
+
}),
|
|
565
|
+
"bundle_already_archived": Object.freeze({
|
|
566
|
+
code: "bundle_already_archived",
|
|
567
|
+
category: "config",
|
|
568
|
+
source: "SDK_local",
|
|
569
|
+
status: "planned",
|
|
570
|
+
httpStatus: null,
|
|
571
|
+
retryable: false,
|
|
572
|
+
sdkClass: "GislBundleAlreadyArchivedError",
|
|
573
|
+
description: "PLANNED — not reachable in any shipped build. Raised when a bundle operation targets an already-archived bundle, once `.bundle()` ships. Do not write handler code against this yet.",
|
|
574
|
+
metadataSchema: Object.freeze({}),
|
|
575
|
+
}),
|
|
576
|
+
"fan_out_timeout": Object.freeze({
|
|
577
|
+
code: "fan_out_timeout",
|
|
578
|
+
category: "network",
|
|
579
|
+
source: "SDK_local",
|
|
580
|
+
status: "wired",
|
|
581
|
+
httpStatus: null,
|
|
582
|
+
retryable: true,
|
|
583
|
+
sdkClass: "GislFanOutTimeoutError",
|
|
584
|
+
description: "A fan-out deadline elapsed before all children finished. The three metadata fields are the reason this is declared separately from GislTimeoutError: completedWorkflowIds are the children that DID finish, parentWorkflowId ran to completion before the fan-out began, and the inherited workflowId is the in-flight child — absent on a clean between-children timeout.",
|
|
585
|
+
metadataSchema: Object.freeze({
|
|
586
|
+
"completedWorkflowIds": "array",
|
|
587
|
+
"parentWorkflowId": "string",
|
|
588
|
+
"workflowId": "string",
|
|
589
|
+
}),
|
|
590
|
+
}),
|
|
591
|
+
"no_such_key": Object.freeze({
|
|
592
|
+
code: "no_such_key",
|
|
593
|
+
category: "chain",
|
|
594
|
+
source: "SDK_local",
|
|
595
|
+
status: "wired",
|
|
596
|
+
httpStatus: null,
|
|
597
|
+
retryable: false,
|
|
598
|
+
sdkClass: "GislNoSuchKeyError",
|
|
599
|
+
description: "The caller asked for a mapEach result key that does not exist in the output. NOTE the key itself is NOT captured in metadata today — it is interpolated into the message only, so consumers cannot branch on which key was missing.",
|
|
600
|
+
metadataSchema: Object.freeze({}),
|
|
601
|
+
}),
|
|
602
|
+
"result_not_ready": Object.freeze({
|
|
603
|
+
code: "result_not_ready",
|
|
604
|
+
category: "validation",
|
|
605
|
+
source: "SDK_local",
|
|
606
|
+
status: "wired",
|
|
607
|
+
httpStatus: null,
|
|
608
|
+
retryable: true,
|
|
609
|
+
sdkClass: "GislResultNotReadyError",
|
|
610
|
+
description: "The caller read a result before the workflow reached a terminal state. `state` carries the non-terminal status observed.",
|
|
611
|
+
metadataSchema: Object.freeze({
|
|
612
|
+
"workflowId": "string",
|
|
613
|
+
"state": "string",
|
|
614
|
+
}),
|
|
615
|
+
}),
|
|
616
|
+
"sink_error": Object.freeze({
|
|
617
|
+
code: "sink_error",
|
|
618
|
+
category: "config",
|
|
619
|
+
source: "SDK_local",
|
|
620
|
+
status: "wired",
|
|
621
|
+
httpStatus: null,
|
|
622
|
+
retryable: false,
|
|
623
|
+
sdkClass: "GislSinkError",
|
|
624
|
+
description: "A caller-supplied output sink could not be used. Branch on `reason` — the values span both caller setup (invalid_directory, duplicate_filename) and runtime write outcomes (write_failed, partial_failure), so the category is a best fit rather than an exact one.",
|
|
625
|
+
metadataSchema: Object.freeze({
|
|
626
|
+
"reason": "string",
|
|
627
|
+
}),
|
|
628
|
+
}),
|
|
629
|
+
"item_failed": Object.freeze({
|
|
630
|
+
code: "item_failed",
|
|
631
|
+
category: "api",
|
|
632
|
+
source: "SDK_local",
|
|
633
|
+
status: "wired",
|
|
634
|
+
httpStatus: null,
|
|
635
|
+
retryable: false,
|
|
636
|
+
sdkClass: "GislItemFailedError",
|
|
637
|
+
description: "CARRIED, not thrown — an entry in a per-item failed[] collection describing one item's server-side failure. `errorCode` carries the server's own code where present; prefer branching on that over this entry.",
|
|
638
|
+
metadataSchema: Object.freeze({
|
|
639
|
+
"key": "string",
|
|
640
|
+
"state": "string",
|
|
641
|
+
"errorMessage": "string",
|
|
642
|
+
"errorCode": "string",
|
|
643
|
+
}),
|
|
644
|
+
}),
|
|
509
645
|
});
|
|
510
646
|
export const ERROR_CATEGORIES = Object.freeze({
|
|
511
647
|
api: Object.freeze([
|
|
@@ -522,20 +658,28 @@ export const ERROR_CATEGORIES = Object.freeze({
|
|
|
522
658
|
"requires_reencode",
|
|
523
659
|
"image_dimensions_too_large",
|
|
524
660
|
"workflow_failed",
|
|
661
|
+
"long_form_concurrency_limit_exceeded",
|
|
662
|
+
"item_failed",
|
|
525
663
|
]),
|
|
526
664
|
config: Object.freeze([
|
|
527
665
|
"missing_credentials",
|
|
528
666
|
"feature_requires_auth",
|
|
667
|
+
"config_error",
|
|
668
|
+
"bundle_already_archived",
|
|
669
|
+
"sink_error",
|
|
529
670
|
]),
|
|
530
671
|
network: Object.freeze([
|
|
531
672
|
"timeout",
|
|
532
673
|
"aborted",
|
|
533
674
|
"upload_failed",
|
|
675
|
+
"fan_out_timeout",
|
|
534
676
|
]),
|
|
535
677
|
auth: Object.freeze([
|
|
536
678
|
"auth_failed",
|
|
537
679
|
"multipart_session_ownership",
|
|
538
680
|
"multipart_session_auth_required",
|
|
681
|
+
"unprocessable_entity",
|
|
682
|
+
"email_same",
|
|
539
683
|
]),
|
|
540
684
|
validation: Object.freeze([
|
|
541
685
|
"multipart_part_invalid",
|
|
@@ -551,11 +695,13 @@ export const ERROR_CATEGORIES = Object.freeze({
|
|
|
551
695
|
"missing_dependency",
|
|
552
696
|
"unsupported_value",
|
|
553
697
|
"type_mismatch",
|
|
698
|
+
"result_not_ready",
|
|
554
699
|
]),
|
|
555
700
|
chain: Object.freeze([
|
|
556
701
|
"undeclared_asset",
|
|
557
702
|
"unused_asset",
|
|
558
703
|
"per_input_options_not_supported",
|
|
559
704
|
"chain_cardinality_mismatch",
|
|
705
|
+
"no_such_key",
|
|
560
706
|
]),
|
|
561
707
|
});
|
package/dist/index.core.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { parseSseStream } from './sse.js';
|
|
|
3
3
|
export type { CreditsUsageOptions, ListWorkflowsOptions, GetSchemaOptions, GetSchemaResult, CapabilitiesSnapshot, PreflightClipError, PreflightClipsResult, ProbeWaitOptions, ProbeWaitResult, GislClientConfig, GislSseEvent, GislSseParseFailure, UploadOptions, WaitOptions, WorkflowCreatePayload, OperationDef, WorkflowSourcePayload, MultiInputSourcePayload, UploadSourcePayload, JobOutputSourcePayload, ExternalImportSourcePayload, ConnectionSourcePayload, JobInputV2Payload, JobDefinitionPayload, ExternalDestinationPayload, DeliveryPayload, DeliveryModePayload, DeliveryBundleFormatPayload, DeliverySelectionPayload, DeliverySelectionTypePayload, DeliveryOutputRefPayload, WorkflowProcessingPayload, ProcessingClassHintPayload, MultipartCheckpointState, _Sdk3HandCodedUploadedPart, _Sdk3HandCodedMultipartStatusResult, _Sdk3HandCodedPresignedPart, _Sdk3HandCodedPresignPartsResult, _Sdk3HandCodedKeepaliveResult, } from './types.js';
|
|
4
4
|
export { uploadSource, jobOutputSource, externalImportSource, connectionSource, } from './types.js';
|
|
5
5
|
export type { GislConfigErrorMetadata } from './errors.js';
|
|
6
|
-
export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislLongFormConcurrencyError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError, GislAbortError, GislNetworkError, GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError, GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedError, GislChainCardinalityMismatchError, GislBundleAlreadyArchivedError, GislNoSuchKeyError, GislSinkError, GislItemFailedError, GislResultNotReadyError, } from './errors.js';
|
|
6
|
+
export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislLongFormConcurrencyError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError, GislFanOutTimeoutError, GislAbortError, GislNetworkError, GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError, GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedError, GislChainCardinalityMismatchError, GislBundleAlreadyArchivedError, GislNoSuchKeyError, GislSinkError, GislItemFailedError, GislResultNotReadyError, } from './errors.js';
|
|
7
7
|
export type { GislApiErrorOptions, GislUploadCapKind } from './errors.js';
|
|
8
8
|
export type { ErrorCategory } from './generated/sdk_spec/errors.js';
|
|
9
9
|
export { RunResult } from './file-first.js';
|
package/dist/index.core.js
CHANGED
|
@@ -11,7 +11,10 @@ export { uploadSource, jobOutputSource, externalImportSource, connectionSource,
|
|
|
11
11
|
// Errors
|
|
12
12
|
export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislLongFormConcurrencyError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError,
|
|
13
13
|
// SDK-3 (Wb6ebOMM) — typed errors for the 3 resume-support endpoints.
|
|
14
|
-
GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError,
|
|
14
|
+
GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError,
|
|
15
|
+
// 4G4FaA9X — mapEach fan-out timed out mid-batch; carries the completed
|
|
16
|
+
// child ids + parent id so the caller can recover without a whole-batch re-run.
|
|
17
|
+
GislFanOutTimeoutError, GislAbortError,
|
|
15
18
|
// FF2b / tywwynmN — transport-level failure (mirrors PHP GislNetworkError);
|
|
16
19
|
// raised by the file-first HttpDownloader when an output URL cannot be read.
|
|
17
20
|
GislNetworkError,
|
package/dist/merge.d.ts
CHANGED
|
@@ -103,6 +103,29 @@ export interface MergeOptions {
|
|
|
103
103
|
readonly preset?: string;
|
|
104
104
|
/** Video output dimensions `WxH` (e.g. `"1920x1080"`); omit to inherit from inputs. Video merge only. */
|
|
105
105
|
readonly targetResolution?: string;
|
|
106
|
+
/**
|
|
107
|
+
* Target output size (bytes, or a `'50MB'`-style string). Lowered to wire
|
|
108
|
+
* `target_size_bytes`, and the SDK also sets `encoding_mode: 'target_size'` alongside
|
|
109
|
+
* it. Video merge only.
|
|
110
|
+
*
|
|
111
|
+
* **UNITS ARE DECIMAL HERE (1 KB = 1000), UNLIKE `compress`.** `compress`'s
|
|
112
|
+
* `targetSize` parses the same strings as BINARY (1 KB = 1024), so `'50MB'` means
|
|
113
|
+
* 50,000,000 bytes on a merge and 52,428,800 bytes on a compress. That divergence is
|
|
114
|
+
* NOT deliberate — it contradicts the pinned convention that every human-readable
|
|
115
|
+
* size string in this SDK is binary — and it has a sharp edge: the contract floor for
|
|
116
|
+
* `target_size_bytes` is 1 MiB (1,048,576), so `'1MB'` here resolves to 1,000,000 and
|
|
117
|
+
* is rejected as below the minimum. Prefer an explicit byte count until this is
|
|
118
|
+
* reconciled. Tracked by `YOCz0i74`; changing it moves bytes for existing callers, so
|
|
119
|
+
* it is a deliberate decision rather than a silent correction.
|
|
120
|
+
*
|
|
121
|
+
* **NOT AVAILABLE FOR LONG INPUTS.** Merges whose summed input duration routes to
|
|
122
|
+
* the long-form Fargate path reject both keys — that path is single-pass-CRF by
|
|
123
|
+
* construction and two-pass target-size is unbuilt. The request fails during
|
|
124
|
+
* execution, and the SDK cannot warn earlier: the routing decision is made
|
|
125
|
+
* server-side at create-plan time, so there is nothing here to check it against.
|
|
126
|
+
* Short-form merges honour it normally. Tracked by `zJN6XIi5`, blocked on a
|
|
127
|
+
* contract that can express per-execution-path availability.
|
|
128
|
+
*/
|
|
106
129
|
readonly targetSize?: string | number;
|
|
107
130
|
readonly transitionDuration?: number;
|
|
108
131
|
readonly fps?: number;
|
package/dist/sse.js
CHANGED
|
@@ -105,7 +105,55 @@ export async function* parseSseStream(response, opts = {}) {
|
|
|
105
105
|
continue;
|
|
106
106
|
}
|
|
107
107
|
if (line.startsWith(':')) {
|
|
108
|
-
// Comment line (keep-alive), skip
|
|
108
|
+
// Comment line (keep-alive), skip.
|
|
109
|
+
//
|
|
110
|
+
// ⚠️ DO NOT "FIX" THIS INTO SURFACING COMMENT FRAMES WITHOUT READING
|
|
111
|
+
// THIS. Dropping them is CORRECT per the SSE spec — a comment carries
|
|
112
|
+
// no event — but it is also LOAD-BEARING FOR A CONSUMER, and that
|
|
113
|
+
// consumer is in another repo.
|
|
114
|
+
//
|
|
115
|
+
// The frontend does NOT close its SSE reader on the terminal event:
|
|
116
|
+
// `workflow_completed` patches status and breaks WITHOUT aborting.
|
|
117
|
+
// What actually closes the stream is an IDLE WATCHDOG at 20s. That
|
|
118
|
+
// watchdog only fires because heartbeats are never yielded here, so
|
|
119
|
+
// they never re-arm it — roughly 20s after the last real event the
|
|
120
|
+
// reader closes and the PHP worker is released.
|
|
121
|
+
//
|
|
122
|
+
// HEARTBEAT ~16s vs IDLE TIMEOUT 20s: a FOUR-SECOND MARGIN that holds
|
|
123
|
+
// only while the heartbeats are invisible. Surfacing comment frames —
|
|
124
|
+
// a completely reasonable change, since EventSource semantics treat
|
|
125
|
+
// keep-alive comments as liveness signals that legitimately reset
|
|
126
|
+
// timeouts — would CONTINUOUSLY re-arm the watchdog. The stream would
|
|
127
|
+
// never go idle, and every completed job left on screen would hold a
|
|
128
|
+
// PHP worker for the full 570s. That is worker exhaustion from
|
|
129
|
+
// ordinary users leaving tabs open.
|
|
130
|
+
//
|
|
131
|
+
// A ONE-LINE CHANGE HERE SILENTLY CONVERTS THE FRONTEND FROM BOUNDED
|
|
132
|
+
// TO UNBOUNDED, and nothing on either side would flag it.
|
|
133
|
+
//
|
|
134
|
+
// ⚠️ THIS NOTE IS PERMANENT. DO NOT DELETE IT WHEN THE FRONTEND
|
|
135
|
+
// ADDS AN EXPLICIT CLOSE-ON-TERMINAL. That fix removes the TERMINAL
|
|
136
|
+
// case and does NOT remove the dependency, because closing finished
|
|
137
|
+
// streams was never the watchdog's job — that was a side effect
|
|
138
|
+
// nobody knew about until 2026-08-12.
|
|
139
|
+
//
|
|
140
|
+
// THE WATCHDOG'S ACTUAL JOB IS DETECTING A STREAM THAT HAS GONE
|
|
141
|
+
// SILENT WHILE THE WORKFLOW IS STILL RUNNING, and resuming the
|
|
142
|
+
// fallback poll. That case survives every fix in flight, and it is
|
|
143
|
+
// not exotic: a long operation between progress events produces it
|
|
144
|
+
// exactly — heartbeats flowing, no data frames, job still running.
|
|
145
|
+
//
|
|
146
|
+
// If this parser surfaced comment frames, heartbeats at ~16s would
|
|
147
|
+
// re-arm the 20s watchdog FOREVER and STALL DETECTION WOULD NEVER
|
|
148
|
+
// FIRE AT ALL, on a stream that is genuinely stuck. The UI would go
|
|
149
|
+
// on believing a dead job is fine. That is worse than the worker
|
|
150
|
+
// leak: a leak is bounded by 570s, a missed stall is not bounded at
|
|
151
|
+
// all.
|
|
152
|
+
//
|
|
153
|
+
// The PHP SDK does the same thing at GislClient.php (`$line[0] === ':'`),
|
|
154
|
+
// verified 2026-08-12 — the two languages agree on this axis, and
|
|
155
|
+
// they must stay agreed: this property governs whether a stuck
|
|
156
|
+
// stream is EVER detected, so a divergence here is not cosmetic.
|
|
109
157
|
continue;
|
|
110
158
|
}
|
|
111
159
|
const colonIndex = line.indexOf(':');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@giveitsmaller/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Node.js SDK for the GISL (Give It Smaller) file compression and processing API",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node": ">=18"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@giveitsmaller/contracts": "^0.
|
|
34
|
+
"@giveitsmaller/contracts": "^0.65.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^22",
|