@giveitsmaller/sdk 0.22.0 → 0.25.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 CHANGED
@@ -74,23 +74,26 @@ development against staging** — which is the one most likely to bite first, be
74
74
  bug in your code.
75
75
 
76
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.
77
+ have *different* CORS policies today. The main API host allows a **list** of origins and the
78
+ **staging** list includes the usual localhost dev ports — so a browser on `localhost` works against
79
+ staging today. The stream host is configured with exactly **one** origin. So everything keeps
80
+ working right up until live progress, and then fails with a CORS error which reads like a mistake
81
+ in your own application rather than a deployment setting.
83
82
 
84
83
  **Production allows only the production web app on both hosts**, and always has.
85
84
 
86
85
  **Workaround:** pass `useSSE: false` to `run()`. The SDK falls back to polling, which goes to the
87
86
  main API host and is unaffected. Everything else about the call is identical.
88
87
 
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.
88
+ **Why it is not a one-line config change — and why it is NOT impossible.** An earlier version of
89
+ this section said multi-origin support could not be done on this host. **That was wrong, and it is
90
+ corrected here.** What is true: `Access-Control-Allow-Origin` accepts exactly one origin (a
91
+ comma-separated list is not valid), and because the stream can be cookie-credentialed, the CORS
92
+ specification also forbids answering `*` alongside `Access-Control-Allow-Credentials: true`. What
93
+ does **not** follow is that more origins are unreachable. The server can validate the request's
94
+ `Origin` and echo it back, which is how every multi-origin credentialed endpoint works. That is a
95
+ **change somebody has to build and get right**, not a platform prohibition — so treat this as
96
+ unshipped work with its own correctness risk, not as a closed door.
94
97
 
95
98
  **On authentication:** prefer an API key (`bearerAuth`) or the anonymous capability token on the
96
99
  stream host. Cookie/session auth is accepted by the endpoint but a *credentialed cross-origin*
@@ -104,6 +107,39 @@ same reason.
104
107
  > test and canary we run passes while a consumer on another origin fails. It is written here because
105
108
  > nothing else would tell you.
106
109
 
110
+ ## Pointing the event stream at its own host
111
+
112
+ Live progress is served from a **second host**, separate from `baseUrl`. The SDK reads that host
113
+ from the contract's declaration — it **never derives** `stream.*` from `api.*`.
114
+
115
+ ```ts
116
+ // Resolved from the environment's declared stream host.
117
+ const client = await gisl.create({ apiKey, environment: 'staging' });
118
+
119
+ // Or point it explicitly. This moves the STREAM only — uploads, workflow-create
120
+ // and downloads still go to baseUrl.
121
+ const client = await gisl.create({
122
+ apiKey,
123
+ environment: 'staging',
124
+ streamBaseUrl: 'https://stream.example.com',
125
+ });
126
+ ```
127
+
128
+ `GISL_STREAM_BASE_URL` does the same thing from the environment. Precedence matches `baseUrl`:
129
+ explicit argument, then `environment`, then the env var.
130
+
131
+ ⚠️ **If nothing declares a stream host, the SDK does not fall back to `baseUrl`.** `streamEvents()`
132
+ throws `GislStreamHostNotDeclaredError` (a `GislConfigError`), and `run()` silently uses polling
133
+ instead, which is a working transport. **This is deliberate.** Guessing the stream host from the API
134
+ host is a convention, and the last time a client did that it streamed into a gateway that cannot
135
+ stream, invisibly — a silent fallback looks exactly like a working one.
136
+
137
+ Both `prod` and `staging` resolve to their contract-declared hosts (production landed with
138
+ contracts `v2.195.0`), and an **unconfigured** client resolves production for the stream just as it
139
+ already did for the API. What still fails closed is a **custom** host: pass your own `baseUrl` (or
140
+ set `GISL_BASE_URL`) with no `streamBaseUrl` and `streamEvents()` raises rather than guessing that
141
+ your proxy's stream lives at production.
142
+
107
143
  ## Documentation
108
144
 
109
145
  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, GislFanOutTimeoutError, GislNetworkError, SseEndedWithoutTerminal } from './errors.js';
29
+ import { GislTimeoutError, GislFanOutTimeoutError, GislNetworkError, GislStreamHostNotDeclaredError, GislTransportError, 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).
@@ -403,7 +403,17 @@ export class OperationBuilder {
403
403
  // Everything else — timeout, abort, API error, an onProgress callback
404
404
  // throw, anything unexpected — MUST propagate; re-issuing the same doomed
405
405
  // request via poll would mask the real failure.
406
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
406
+ if (!(err instanceof SseEndedWithoutTerminal ||
407
+ err instanceof GislNetworkError ||
408
+ // VUozk5Bc: no stream host is DECLARED for this configuration (a
409
+ // configuration nothing declares; both named environments resolve as of
410
+ // contracts v2.195.0). That is not a failure to recover from,
411
+ // it is SSE being unavailable here, and polling is a working
412
+ // transport. Failing hard instead would strand every caller on a host
413
+ // nobody has declared yet. A DIRECT `streamEvents` caller still gets
414
+ // the hard error — they asked for the stream specifically; a `run()`
415
+ // caller asked for a result.
416
+ err instanceof GislStreamHostNotDeclaredError)) {
407
417
  throw err;
408
418
  }
409
419
  // Genuine SSE stream-end / transport error — fall through to poll fallback.
@@ -592,7 +602,7 @@ export async function _consumeSseToTerminal(client, args) {
592
602
  // GislNetworkError so the await-terminal callers poll-fallback on it
593
603
  // (and ONLY on it / a clean stream-end), never on an onProgress throw.
594
604
  if (err instanceof TypeError) {
595
- throw new GislNetworkError(`SSE connect to workflow ${args.workflowId} events failed: ${err.message}`);
605
+ throw new GislTransportError(`SSE connect to workflow ${args.workflowId} events failed: ${err.message}`);
596
606
  }
597
607
  throw err;
598
608
  }
@@ -674,11 +684,12 @@ export async function _consumeSseToTerminal(client, args) {
674
684
  throw new GislTimeoutError(`Workflow ${args.workflowId} did not complete before maxWait deadline`, args.workflowId);
675
685
  }
676
686
  // A genuine mid-stream TRANSPORT failure (reader disconnect) surfaces as a
677
- // raw `TypeError` from the iterator — wrap as GislNetworkError so callers
687
+ // raw `TypeError` from the iterator — wrap as GislTransportError so callers
678
688
  // poll-fallback. (An onProgress throw was already handled above, so a
679
- // TypeError here is unambiguously transport.)
689
+ // TypeError here is unambiguously transport.) It stays a GislNetworkError
690
+ // by inheritance, so the poll-fallback gates below are unchanged.
680
691
  if (innerErr instanceof TypeError) {
681
- throw new GislNetworkError(`SSE stream for workflow ${args.workflowId} failed mid-stream: ${innerErr.message}`);
692
+ throw new GislTransportError(`SSE stream for workflow ${args.workflowId} failed mid-stream: ${innerErr.message}`);
682
693
  }
683
694
  throw innerErr;
684
695
  }
package/dist/client.d.ts CHANGED
@@ -13,6 +13,13 @@ export interface ValidationDetail {
13
13
  }
14
14
  export declare class GislClient {
15
15
  private readonly baseUrl;
16
+ /**
17
+ * Declared SSE stream host, or `null` when nothing declares one for this
18
+ * configuration. `null` is a legitimate state, not a misconfiguration —
19
+ * see `streamEvents`, which fails closed on it rather than falling back to
20
+ * `baseUrl`.
21
+ */
22
+ private readonly streamBaseUrl;
16
23
  private readonly headers;
17
24
  private readonly timeoutMs;
18
25
  private readonly multipartThreshold;
package/dist/client.js CHANGED
@@ -5,7 +5,11 @@
5
5
  // dynamic one) so `vi.mock('node:fs/promises')` still intercepts it in tests.
6
6
  import { open, stat, basename } from './node-fs.js';
7
7
  import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, AccountLimitsFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowListResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, AuthRejectionEnvelopeFromJSON, AuthRejectionEnvelopeErrorTypeEnum, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, LongFormConcurrencyLimitResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, ProbePendingResponseFromJSON, UploadSizeExceedsTierResponseFromJSON, UploadDurationExceedsTierResponseFromJSON, UploadConstraintsAppliedProcessingClassPreAssignmentEnum, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
8
- import { GislAbortError, GislApiError, GislAuthError, GislAuthRejectionError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislLongFormConcurrencyError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislProbePendingError, GislUploadCapExceededError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
8
+ import { GislAbortError, GislApiError, GislAuthError, GislAuthRejectionError, GislBalanceExhaustedError, GislConfigError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislLongFormConcurrencyError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislProbePendingError, GislStreamHostNotDeclaredError, GislUploadCapExceededError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
9
+ // Stream-host vocabulary for the fail-closed `streamEvents` guard. The
10
+ // resolver itself runs in `gisl.create()`; the client only reports what a
11
+ // caller can do about an absent host.
12
+ import { declaredStreamEnvironments, GISL_STREAM_BASE_URL_ENV, } from './credentials.js';
9
13
  // The `Retry-After` millisecond parser lives in the shared retry-metadata
10
14
  // module (extracted to break the client ↔ errors circular import); re-imported
11
15
  // here so the retry-loop timing stays byte-identical.
@@ -350,8 +354,60 @@ function fileByteSource(path, size) {
350
354
  },
351
355
  };
352
356
  }
357
+ /**
358
+ * Normalise a configured stream host to an absolute origin, or `null` when
359
+ * none was supplied. Trailing slashes are stripped so path concatenation does
360
+ * not double-separate.
361
+ *
362
+ * ⚠️ **A PRESENT-BUT-MALFORMED VALUE THROWS RATHER THAN DEGRADING TO `null`,
363
+ * and the distinction is deliberate.** Absent means "nobody declared one" — a
364
+ * legitimate state that `run()` handles by polling. A caller who passed
365
+ * `'/'` or `'stream.example.com'` did declare one, and got it wrong.
366
+ * Quietly converting that to "absent" would send their stream somewhere they
367
+ * did not choose (a bare `'/'` normalises to `''`, which concatenates into a
368
+ * RELATIVE url) and hand them a poll they never asked for — the silent
369
+ * degradation this whole mechanism exists to refuse, one layer further down.
370
+ *
371
+ * An empty OR WHITESPACE-ONLY string is treated as unset — the two are
372
+ * indistinguishable in intent — matching how `locale` handles `''` elsewhere in
373
+ * this config.
374
+ */
375
+ function normaliseStreamBaseUrl(value) {
376
+ if (value === undefined)
377
+ return null;
378
+ const trimmed = value.trim();
379
+ if (trimmed === '')
380
+ return null;
381
+ let parsed;
382
+ try {
383
+ parsed = new URL(trimmed);
384
+ }
385
+ catch {
386
+ throw new GislConfigError(`streamBaseUrl must be an absolute http(s) URL (e.g. https://stream.example.com); got '${value}'.`);
387
+ }
388
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
389
+ throw new GislConfigError(`streamBaseUrl must use http or https; got protocol '${parsed.protocol}' in '${value}'.`);
390
+ }
391
+ // A query or fragment cannot survive path concatenation: the events path
392
+ // is appended as a STRING, so `https://host?token=x` would request `/`
393
+ // with the whole events path buried inside the query value. Rejecting is
394
+ // right rather than stripping — a caller who put a token there meant it to
395
+ // be sent, and silently dropping it would fail later and further away.
396
+ // codex 5793a3be0f7b.
397
+ if (parsed.search !== '' || parsed.hash !== '') {
398
+ throw new GislConfigError(`streamBaseUrl must not carry a query or fragment (the events path is appended to it); got '${value}'.`);
399
+ }
400
+ return trimmed.replace(/\/+$/, '');
401
+ }
353
402
  export class GislClient {
354
403
  baseUrl;
404
+ /**
405
+ * Declared SSE stream host, or `null` when nothing declares one for this
406
+ * configuration. `null` is a legitimate state, not a misconfiguration —
407
+ * see `streamEvents`, which fails closed on it rather than falling back to
408
+ * `baseUrl`.
409
+ */
410
+ streamBaseUrl;
355
411
  headers;
356
412
  timeoutMs;
357
413
  multipartThreshold;
@@ -361,6 +417,11 @@ export class GislClient {
361
417
  useSessionCookie;
362
418
  constructor(config) {
363
419
  this.baseUrl = config.baseUrl.replace(/\/+$/, '');
420
+ // NOT defaulted to `baseUrl`. An absent stream host stays absent so
421
+ // `streamEvents` can fail closed and name the missing declaration; a
422
+ // default here would be the silent derivation this whole mechanism exists
423
+ // to prevent, hidden one layer deeper than the resolver.
424
+ this.streamBaseUrl = normaliseStreamBaseUrl(config.streamBaseUrl);
364
425
  this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
365
426
  this.useSessionCookie = config.useSessionCookie ?? false;
366
427
  // Floor the threshold at the first-chunk size: the multipart initiate
@@ -399,7 +460,7 @@ export class GislClient {
399
460
  if (opts.signal?.aborted) {
400
461
  throw new GislAbortError(`Request to ${method} ${path} aborted`);
401
462
  }
402
- const url = `${this.baseUrl}${path}`;
463
+ const url = `${opts.baseUrl ?? this.baseUrl}${path}`;
403
464
  const headers = { ...this.headers, ...opts.headers };
404
465
  let body;
405
466
  if (opts.json !== false && opts.body && !(opts.body instanceof FormData)) {
@@ -1748,6 +1809,24 @@ export class GislClient {
1748
1809
  */
1749
1810
  async streamEvents(workflowId, opts = {}) {
1750
1811
  const eventsPath = `/api/workflows/${encodeURIComponent(workflowId)}/events`;
1812
+ // FAIL CLOSED. The stream lives on a second host and this SDK will not
1813
+ // guess it. Falling back to `this.baseUrl` here would be the one line that
1814
+ // re-creates, inside a published SDK, the failure this mechanism exists to
1815
+ // prevent: production had no stream host configured, fell back to the API
1816
+ // host by convention, and streamed into a gateway that cannot stream —
1817
+ // invisibly, because a silent fallback looks exactly like a working one.
1818
+ // `run()` handles this case by polling; a direct `streamEvents` caller
1819
+ // asked for the stream specifically and is told plainly that there isn't
1820
+ // one.
1821
+ if (this.streamBaseUrl === null) {
1822
+ const declared = declaredStreamEnvironments();
1823
+ throw new GislStreamHostNotDeclaredError('No SSE stream host is declared for this configuration, and the SDK does not derive one ' +
1824
+ `from baseUrl. ${declared.length > 0
1825
+ ? `Environments that declare a stream host: ${declared.join(', ')}.`
1826
+ : 'No environment currently declares a stream host.'} Pass {streamBaseUrl} to gisl.create() / new GislClient(), set ` +
1827
+ `${GISL_STREAM_BASE_URL_ENV}, or construct with an {environment} that declares one. ` +
1828
+ 'Hosts are read from the contract declaration and are never derived from baseUrl.');
1829
+ }
1751
1830
  // SSE-lifetime AbortController. `request()` builds its own controller
1752
1831
  // and tears it down (`clearTimeout(timer); unbind()`) in its `finally`
1753
1832
  // the instant the response headers arrive — BEFORE the SSE body
@@ -1771,6 +1850,8 @@ export class GislClient {
1771
1850
  rawResponse: true,
1772
1851
  signal: controller.signal,
1773
1852
  headers: workflowCapabilityHeaders(opts.capability),
1853
+ // The one call in the SDK that does NOT go to `baseUrl`.
1854
+ baseUrl: this.streamBaseUrl,
1774
1855
  });
1775
1856
  }
1776
1857
  catch (err) {
@@ -16,6 +16,7 @@
16
16
  export declare const GISL_API_KEY_ENV = "GISL_API_KEY";
17
17
  export declare const GISL_BASE_URL_ENV = "GISL_BASE_URL";
18
18
  export declare const GISL_ENVIRONMENT_ENV = "GISL_ENVIRONMENT";
19
+ export declare const GISL_STREAM_BASE_URL_ENV = "GISL_STREAM_BASE_URL";
19
20
  /**
20
21
  * Named environments → base URLs. Kept colocated with the resolver so the
21
22
  * mapping table doesn't leak into `gisl.ts`.
@@ -26,6 +27,42 @@ export declare const ENVIRONMENT_ENDPOINTS: {
26
27
  };
27
28
  export type Environment = keyof typeof ENVIRONMENT_ENDPOINTS;
28
29
  export declare const DEFAULT_ENDPOINT: "https://api.giveitsmaller.com";
30
+ /**
31
+ * Named environments → **SSE stream host**. A SECOND host, deliberately
32
+ * separate from {@link ENVIRONMENT_ENDPOINTS}: the API host fronts an
33
+ * integration with no response-streaming mode, so the event stream lives on
34
+ * its own public entry point.
35
+ *
36
+ * ⚠️ **DECLARED, NEVER DERIVED.** This table exists because the alternative —
37
+ * transforming `api.*` into `stream.*` by string surgery — is a *convention*,
38
+ * and a convention is exactly what put production on the gateway path: the
39
+ * frontend's prod build had no `VITE_SSE_BASE_URL`, silently fell back to the
40
+ * API host, and nobody could see it. A host is a fact somebody states, not a
41
+ * pattern somebody guesses.
42
+ *
43
+ * PINNED to the generated `availability.json`
44
+ * `endpoints['GET /api/workflows/{id}/events'].servers` by
45
+ * `tests/unit/stream-host-conformance.test.ts`, which fails **closed**: if the
46
+ * contract declares a host this table does not carry (or vice versa), the
47
+ * build breaks. Hand-maintained rather than read at runtime because the SDK
48
+ * ships a browser entry point and does not load `availability.json` — the same
49
+ * table+conformance shape used by `COMPRESS_OPTION_VALUES`,
50
+ * `OUTPUT_OPTION_DEPENDS_ON`, the preset planned gate and the watermark gate.
51
+ *
52
+ * `prod` landed with contracts `v2.195.0` (#410), which declared the production
53
+ * stream host. It is here because the CONTRACT declares it — the entry and the
54
+ * vendored declaration moved in the same change, never ahead of it.
55
+ *
56
+ * ⚠️ **A CONFIGURATION WITH NO DECLARED HOST STILL FAILS CLOSED.** Both entries
57
+ * being present does not soften the rule: {@link resolveStreamEndpoint} returns
58
+ * `null` for anything it cannot resolve from a declaration, and
59
+ * `GislClient.streamEvents` raises rather than quietly reusing `baseUrl`.
60
+ *
61
+ * `localhost` is intentionally absent too: it is declared in the contract as a
62
+ * development server, but there is no `localhost` *environment* name to key it
63
+ * off. Local callers pass `{streamBaseUrl}` or set `GISL_STREAM_BASE_URL`.
64
+ */
65
+ export declare const ENVIRONMENT_STREAM_ENDPOINTS: Partial<Record<Environment, string>>;
29
66
  export interface ResolveCredentialsOptions {
30
67
  /** Explicit API key — highest precedence. */
31
68
  readonly apiKey?: string;
@@ -44,6 +81,12 @@ export interface ResolveCredentialsOptions {
44
81
  export interface ResolveEndpointOptions {
45
82
  readonly baseUrl?: string;
46
83
  readonly environment?: Environment;
84
+ /**
85
+ * Explicit SSE stream host. Highest precedence for stream resolution, and
86
+ * the ONLY knob that moves the stream **without** moving every other call —
87
+ * overriding `baseUrl` moves uploads, workflow-create and downloads too.
88
+ */
89
+ readonly streamBaseUrl?: string;
47
90
  }
48
91
  /**
49
92
  * Resolve the API key via the credential chain. Returns the resolved key,
@@ -59,3 +102,34 @@ export declare function resolveApiKey(opts?: ResolveCredentialsOptions): Promise
59
102
  * resolves to a usable URL.
60
103
  */
61
104
  export declare function resolveEndpoint(opts?: ResolveEndpointOptions): string;
105
+ /**
106
+ * Resolve the **SSE stream host**, or `null` when no host is declared for this
107
+ * configuration. Explicit `streamBaseUrl` wins; otherwise an explicit
108
+ * `environment` name; otherwise `GISL_STREAM_BASE_URL`; otherwise the
109
+ * `GISL_ENVIRONMENT` env var.
110
+ *
111
+ * ⚠️ **RETURNS `null` RATHER THAN FALLING BACK TO `baseUrl`, AND THAT IS THE
112
+ * WHOLE POINT OF THIS FUNCTION.** Deriving the stream host from the API host
113
+ * would reproduce, inside a published SDK, the exact failure this resolver
114
+ * exists to prevent: prod had no stream host configured, fell back to the API
115
+ * host by convention, and landed on the gateway path where the stream cannot
116
+ * work. A silent fallback is not a lenient control — it is the absence of one
117
+ * wearing the control's name. Callers decide what `null` means; see
118
+ * `GislClient.streamEvents`, which fails closed and names the missing
119
+ * declaration.
120
+ *
121
+ * Unlike {@link resolveEndpoint}, there is no default: prod has no declared
122
+ * stream host yet (see {@link ENVIRONMENT_STREAM_ENDPOINTS}), so a default
123
+ * could only be a guess.
124
+ *
125
+ * Throws `GislConfigError` on an unknown explicit `environment` name — the
126
+ * same fail-closed behaviour as {@link resolveEndpoint}, for the same reason
127
+ * (a typo must not silently re-route a stream).
128
+ */
129
+ export declare function resolveStreamEndpoint(opts?: ResolveEndpointOptions): string | null;
130
+ /**
131
+ * Human-readable list of the environments that currently declare a stream
132
+ * host. Used in the fail-closed error message so the caller is told what IS
133
+ * available rather than only what is missing.
134
+ */
135
+ export declare function declaredStreamEnvironments(): readonly string[];
@@ -20,6 +20,7 @@ import { GislConfigError } from './errors.js';
20
20
  export const GISL_API_KEY_ENV = 'GISL_API_KEY';
21
21
  export const GISL_BASE_URL_ENV = 'GISL_BASE_URL';
22
22
  export const GISL_ENVIRONMENT_ENV = 'GISL_ENVIRONMENT';
23
+ export const GISL_STREAM_BASE_URL_ENV = 'GISL_STREAM_BASE_URL';
23
24
  /**
24
25
  * Named environments → base URLs. Kept colocated with the resolver so the
25
26
  * mapping table doesn't leak into `gisl.ts`.
@@ -29,6 +30,45 @@ export const ENVIRONMENT_ENDPOINTS = {
29
30
  staging: 'https://api.staging.giveitsmaller.com',
30
31
  };
31
32
  export const DEFAULT_ENDPOINT = ENVIRONMENT_ENDPOINTS.prod;
33
+ /**
34
+ * Named environments → **SSE stream host**. A SECOND host, deliberately
35
+ * separate from {@link ENVIRONMENT_ENDPOINTS}: the API host fronts an
36
+ * integration with no response-streaming mode, so the event stream lives on
37
+ * its own public entry point.
38
+ *
39
+ * ⚠️ **DECLARED, NEVER DERIVED.** This table exists because the alternative —
40
+ * transforming `api.*` into `stream.*` by string surgery — is a *convention*,
41
+ * and a convention is exactly what put production on the gateway path: the
42
+ * frontend's prod build had no `VITE_SSE_BASE_URL`, silently fell back to the
43
+ * API host, and nobody could see it. A host is a fact somebody states, not a
44
+ * pattern somebody guesses.
45
+ *
46
+ * PINNED to the generated `availability.json`
47
+ * `endpoints['GET /api/workflows/{id}/events'].servers` by
48
+ * `tests/unit/stream-host-conformance.test.ts`, which fails **closed**: if the
49
+ * contract declares a host this table does not carry (or vice versa), the
50
+ * build breaks. Hand-maintained rather than read at runtime because the SDK
51
+ * ships a browser entry point and does not load `availability.json` — the same
52
+ * table+conformance shape used by `COMPRESS_OPTION_VALUES`,
53
+ * `OUTPUT_OPTION_DEPENDS_ON`, the preset planned gate and the watermark gate.
54
+ *
55
+ * `prod` landed with contracts `v2.195.0` (#410), which declared the production
56
+ * stream host. It is here because the CONTRACT declares it — the entry and the
57
+ * vendored declaration moved in the same change, never ahead of it.
58
+ *
59
+ * ⚠️ **A CONFIGURATION WITH NO DECLARED HOST STILL FAILS CLOSED.** Both entries
60
+ * being present does not soften the rule: {@link resolveStreamEndpoint} returns
61
+ * `null` for anything it cannot resolve from a declaration, and
62
+ * `GislClient.streamEvents` raises rather than quietly reusing `baseUrl`.
63
+ *
64
+ * `localhost` is intentionally absent too: it is declared in the contract as a
65
+ * development server, but there is no `localhost` *environment* name to key it
66
+ * off. Local callers pass `{streamBaseUrl}` or set `GISL_STREAM_BASE_URL`.
67
+ */
68
+ export const ENVIRONMENT_STREAM_ENDPOINTS = {
69
+ prod: 'https://stream.giveitsmaller.com',
70
+ staging: 'https://stream.staging.giveitsmaller.com',
71
+ };
32
72
  // ---------------------------------------------------------------------------
33
73
  // Public resolvers
34
74
  // ---------------------------------------------------------------------------
@@ -105,6 +145,89 @@ export function resolveEndpoint(opts = {}) {
105
145
  }
106
146
  return DEFAULT_ENDPOINT;
107
147
  }
148
+ /**
149
+ * Resolve the **SSE stream host**, or `null` when no host is declared for this
150
+ * configuration. Explicit `streamBaseUrl` wins; otherwise an explicit
151
+ * `environment` name; otherwise `GISL_STREAM_BASE_URL`; otherwise the
152
+ * `GISL_ENVIRONMENT` env var.
153
+ *
154
+ * ⚠️ **RETURNS `null` RATHER THAN FALLING BACK TO `baseUrl`, AND THAT IS THE
155
+ * WHOLE POINT OF THIS FUNCTION.** Deriving the stream host from the API host
156
+ * would reproduce, inside a published SDK, the exact failure this resolver
157
+ * exists to prevent: prod had no stream host configured, fell back to the API
158
+ * host by convention, and landed on the gateway path where the stream cannot
159
+ * work. A silent fallback is not a lenient control — it is the absence of one
160
+ * wearing the control's name. Callers decide what `null` means; see
161
+ * `GislClient.streamEvents`, which fails closed and names the missing
162
+ * declaration.
163
+ *
164
+ * Unlike {@link resolveEndpoint}, there is no default: prod has no declared
165
+ * stream host yet (see {@link ENVIRONMENT_STREAM_ENDPOINTS}), so a default
166
+ * could only be a guess.
167
+ *
168
+ * Throws `GislConfigError` on an unknown explicit `environment` name — the
169
+ * same fail-closed behaviour as {@link resolveEndpoint}, for the same reason
170
+ * (a typo must not silently re-route a stream).
171
+ */
172
+ export function resolveStreamEndpoint(opts = {}) {
173
+ // TRIM BEFORE THE PRESENCE CHECK. A whitespace-only value is unset (the
174
+ // client normaliser treats it that way too), and if it were allowed to
175
+ // count as "supplied" here it would SUPPRESS the environment's declared
176
+ // host and then normalise to nothing — silently disabling a stream that
177
+ // was perfectly well declared. codex a7f5ec9f0d32.
178
+ const explicit = opts.streamBaseUrl?.trim() ?? '';
179
+ if (explicit !== '') {
180
+ return explicit;
181
+ }
182
+ if (typeof opts.environment === 'string') {
183
+ if (!(opts.environment in ENVIRONMENT_ENDPOINTS)) {
184
+ throw new GislConfigError(`Unknown environment '${opts.environment}'. Valid values: ${Object.keys(ENVIRONMENT_ENDPOINTS).join(', ')}.`);
185
+ }
186
+ // A KNOWN environment with no declared stream host resolves to `null`, not
187
+ // to an error and not to `baseUrl`: the config is valid, the declaration is
188
+ // simply missing upstream. Both current environments declare one.
189
+ return ENVIRONMENT_STREAM_ENDPOINTS[opts.environment] ?? null;
190
+ }
191
+ const envStreamBaseUrl = readEnv(GISL_STREAM_BASE_URL_ENV);
192
+ if (envStreamBaseUrl !== null && envStreamBaseUrl.length > 0) {
193
+ return envStreamBaseUrl;
194
+ }
195
+ const envEnvironment = readEnv(GISL_ENVIRONMENT_ENV);
196
+ if (envEnvironment !== null) {
197
+ const envMapped = ENVIRONMENT_STREAM_ENDPOINTS[envEnvironment];
198
+ if (envMapped !== undefined) {
199
+ return envMapped;
200
+ }
201
+ }
202
+ // SYMMETRY WITH `resolveEndpoint`, and a correctness fix rather than a
203
+ // convenience (codex 480e8b865b90). `resolveEndpoint` FALLS THROUGH to the
204
+ // production API host when nothing is configured — so an unconfigured
205
+ // `gisl.create({apiKey})` already talks to production, while its stream
206
+ // resolved to `null`. That made THE DEFAULT CONFIGURATION the one that could
207
+ // not stream: `streamEvents()` threw and `run()` silently polled, against a
208
+ // production host whose stream IS declared. The two resolvers have to agree
209
+ // about what "unconfigured" means.
210
+ //
211
+ // ⚠️ ONLY when the API host ALSO defaulted. An explicit `baseUrl` (or
212
+ // `GISL_BASE_URL`) names a host we were told about and cannot reason about —
213
+ // a proxy, a self-host, a test double — so we still refuse rather than assume
214
+ // production's stream host. Assuming there would be deriving one host from
215
+ // another, which is precisely what this mechanism exists to refuse.
216
+ const apiHostWasConfigured = (typeof opts.baseUrl === 'string' && opts.baseUrl.trim() !== '') ||
217
+ readEnv(GISL_BASE_URL_ENV) !== null;
218
+ if (!apiHostWasConfigured) {
219
+ return ENVIRONMENT_STREAM_ENDPOINTS.prod ?? null;
220
+ }
221
+ return null;
222
+ }
223
+ /**
224
+ * Human-readable list of the environments that currently declare a stream
225
+ * host. Used in the fail-closed error message so the caller is told what IS
226
+ * available rather than only what is missing.
227
+ */
228
+ export function declaredStreamEnvironments() {
229
+ return Object.keys(ENVIRONMENT_STREAM_ENDPOINTS);
230
+ }
108
231
  // ---------------------------------------------------------------------------
109
232
  // Internals
110
233
  // ---------------------------------------------------------------------------
@@ -11,9 +11,15 @@ export interface VideoCompressPresetOptionsInput {
11
11
  * single-pass-CRF by construction and two-pass target-size is unbuilt. The request
12
12
  * fails during execution, and the SDK cannot warn earlier — routing is decided
13
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}.
14
+ * The same limit applies to {@link MergeOptions.targetSize}.
15
+ * Short-form compresses honour it normally.
16
+ *
17
+ * The contract CAN now express this — `per_class_availability` scopes an option to
18
+ * a processing class, vendored at v2.195.0 and pinned by
19
+ * `tests/unit/per-class-availability-conformance.test.ts`. That buys an honest 422
20
+ * from the API at CREATE rather than a job dying mid-execution; it does NOT become
21
+ * a client-side gate, because routing is still decided server-side and a duration
22
+ * heuristic here would be wrong at the boundary. Tracked by `zJN6XIi5`.
17
23
  */
18
24
  readonly targetSize?: string | number;
19
25
  readonly crf?: number;