@giveitsmaller/sdk 0.21.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/dist/handle.js CHANGED
@@ -32,7 +32,7 @@
32
32
  *
33
33
  * Mirrors the PHP `Gisl\Sdk\Ergonomic\Handle` + `Gisl\Sdk\Ergonomic\StatusSnapshot`.
34
34
  */
35
- import { GislConfigError, GislNetworkError, GislResultNotReadyError, GislTimeoutError, SseEndedWithoutTerminal, } from './errors.js';
35
+ import { GislConfigError, GislNetworkError, GislResultNotReadyError, GislTimeoutError, GislStreamHostNotDeclaredError, SseEndedWithoutTerminal, } from './errors.js';
36
36
  import { _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, } from './builder.js';
37
37
  import { projectDownloadsToRunResult, projectMultiJobToRunResult, isFanoutStatus, isMergeStatus, isArchiveStatus, isWatermarkStatus, isSoleOpChainStatus, soleOpChainDeliverableRef, _POST_STEP_JOB_REF, } from './file-first.js';
38
38
  import { LazyHttpDownloader } from './lazy-downloader.js';
@@ -172,7 +172,17 @@ export class Handle {
172
172
  // (GislNetworkError). Everything else (timeout, abort, API, an onProgress
173
173
  // callback throw, anything unexpected) MUST propagate — re-issuing the same
174
174
  // doomed request via poll would mask the real failure.
175
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
175
+ if (!(err instanceof SseEndedWithoutTerminal ||
176
+ err instanceof GislNetworkError ||
177
+ // VUozk5Bc: no stream host is DECLARED for this configuration (a
178
+ // configuration nothing declares; both named environments resolve as of
179
+ // contracts v2.195.0). That is not a failure to recover from,
180
+ // it is SSE being unavailable here, and polling is a working
181
+ // transport. Failing hard instead would strand every caller on a host
182
+ // nobody has declared yet. A DIRECT `streamEvents` caller still gets
183
+ // the hard error — they asked for the stream specifically; a `run()`
184
+ // caller asked for a result.
185
+ err instanceof GislStreamHostNotDeclaredError)) {
176
186
  throw err;
177
187
  }
178
188
  finalStatus = await _pollToTerminal(client, {
@@ -7,7 +7,7 @@
7
7
  import { createWriteStream } from 'node:fs';
8
8
  import { Readable } from 'node:stream';
9
9
  import { pipeline } from 'node:stream/promises';
10
- import { GislNetworkError, GislSinkError } from './errors.js';
10
+ import { GislDownloadHttpError, GislRequestNotSentError, GislSinkError, GislTransportError, } from './errors.js';
11
11
  /**
12
12
  * Streams a (typically pre-signed) URL to a local path without buffering the
13
13
  * whole body in memory. Pre-signed download URLs require no SDK auth, so this
@@ -21,22 +21,40 @@ export class HttpDownloader {
21
21
  // on the RunResult sink side. Parity-critical: the FF1 sink contract tells
22
22
  // callers to narrow with instanceof, so the source-read error type must
23
23
  // match across languages.
24
+ // codex f46340e1d58a: a malformed URL fails DETERMINISTICALLY, so it must
25
+ // not land in the always-retryable bucket with DNS and TLS. `fetch` rejects
26
+ // both with an indistinguishable TypeError, so the only way to tell them
27
+ // apart is to check BEFORE the call — which also gives
28
+ // GislRequestNotSentError a real throw site in TypeScript rather than
29
+ // leaving it declared-but-dormant.
30
+ try {
31
+ new URL(url);
32
+ }
33
+ catch {
34
+ throw new GislRequestNotSentError(`Download source is not a valid URL: ${url}`);
35
+ }
24
36
  let res;
25
37
  try {
26
38
  res = await fetch(url);
27
39
  }
28
40
  catch (cause) {
29
41
  // A rejected fetch (DNS, TCP, TLS, mid-flight disconnect) must surface as
30
- // GislNetworkError too — not the raw TypeError — so callers can narrow
31
- // every download-source failure with `instanceof GislNetworkError`
32
- // (codex review medium).
33
- throw new GislNetworkError(`Failed to fetch download source: ${cause instanceof Error ? cause.message : String(cause)}`);
42
+ // a typed error — not the raw TypeError — so callers can narrow every
43
+ // download-source failure with `instanceof GislNetworkError`
44
+ // (codex review medium). t2qCrjdr: TRANSPORT specifically, so the
45
+ // retry advice is `true` here and status-derived below.
46
+ throw new GislTransportError(`Failed to fetch download source: ${cause instanceof Error ? cause.message : String(cause)}`);
34
47
  }
35
48
  if (!res.ok) {
36
- throw new GislNetworkError(`Download failed with status ${res.status}`);
49
+ // t2qCrjdr: the server was REACHED and refused. Carries the status so a
50
+ // consumer telling a permanent 404 from a transient 503 never has to
51
+ // parse the message.
52
+ throw new GislDownloadHttpError(`Download failed with status ${res.status}`, res.status);
37
53
  }
38
54
  if (res.body === null) {
39
- throw new GislNetworkError('Download response had no body');
55
+ // 2xx with nothing in it — the server did not refuse, it under-delivered.
56
+ // Transport rather than HTTP, and retrying is the right advice.
57
+ throw new GislTransportError('Download response had no body');
40
58
  }
41
59
  // `fetch`'s WHATWG ReadableStream and Node's `stream/web` ReadableStream
42
60
  // are structurally the same at runtime but typed in two different lib
@@ -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, GislTransportError, GislDownloadHttpError, GislRequestNotSentError, GislConfigError, GislMissingCredentialsError, GislStreamHostNotDeclaredError, 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';
@@ -33,7 +33,7 @@ export type { SseOperationProgressData, SseOperationCompletedData, SseOperationF
33
33
  export type { MultiOutputCompletion, PageIndexed, PositionIndexed, Unindexed, } from '@giveitsmaller/contracts/asyncapi';
34
34
  import type { MultiOutputCompletion as _MultiOutputCompletion } from '@giveitsmaller/contracts/asyncapi';
35
35
  export type OperationResultOutputEntry = _MultiOutputCompletion['outputs'][number];
36
- export type { CompressImageOptions, CompressImageJpegOptions, CompressImagePngOptions, CompressImageAvifOptions, CompressVideoOptions, CompressAudioOptions, CompressDocumentOfficeOptions, CompressDocumentOdfOptions, CompressDocumentEpubOptions, ThumbnailImageOptions, ThumbnailVideoOptions, ThumbnailDocumentOptions, TransformImageOptions, TransformImageGifOptions, TransformVideoOptions, TransformDocumentPdfOptions, ConvertImageOptions, ConvertVideoOptions, ConvertAudioOptions, ConvertDocumentPdfOptions, MergeImageOptions, MergeVideoOptions, MergeVideoPerInputOptions, MergeAudioOptions, MergeAudioPerInputOptions, ArchiveOptions, ImageWatermarkImageOptions, ImageWatermarkImageGifOptions, TextWatermarkImageOptions, CustomLumaVideoOptions, AudioOverlayAudioOptions, AudioOverlayVideoOptions, AudioWatermarkAudioOptions, AudioWatermarkVideoOptions, AudioToVideoAudioOptions, VideoWatermarkVideoOptions, VideoTextWatermarkVideoOptions, SplitImageGifOptions, SplitDocumentPdfOptions, SplitAudioOptions, SplitVideoOptions, } from '@giveitsmaller/contracts/operations';
36
+ export type { CompressImageOptions, CompressImageJpegOptions, CompressImagePngOptions, CompressImageAvifOptions, CompressVideoOptions, CompressAudioOptions, CompressDocumentOfficeOptions, CompressDocumentOdfOptions, CompressDocumentEpubOptions, ThumbnailImageOptions, ThumbnailVideoOptions, ThumbnailDocumentOfficeOptions, ThumbnailDocumentPdfOptions, ThumbnailDocumentEpubOptions, TransformImageOptions, TransformImageGifOptions, TransformVideoOptions, TransformDocumentPdfOptions, ConvertImageOptions, ConvertVideoOptions, ConvertAudioOptions, ConvertDocumentPdfOptions, MergeImageOptions, MergeVideoOptions, MergeVideoPerInputOptions, MergeAudioOptions, MergeAudioPerInputOptions, ArchiveOptions, ImageWatermarkImageOptions, ImageWatermarkImageGifOptions, TextWatermarkImageOptions, CustomLumaVideoOptions, AudioOverlayAudioOptions, AudioOverlayVideoOptions, AudioWatermarkAudioOptions, AudioWatermarkVideoOptions, AudioToVideoAudioOptions, VideoWatermarkVideoOptions, VideoTextWatermarkVideoOptions, SplitImageGifOptions, SplitDocumentPdfOptions, SplitAudioOptions, SplitVideoOptions, } from '@giveitsmaller/contracts/operations';
37
37
  export { ImageWatermarkImageAnchor, ImageWatermarkImageGifAnchor, TextWatermarkImageAnchor, TextWatermarkImageFontFamily, TextWatermarkImageWatermarkMode, AudioOverlayAudioMode, AudioOverlayVideoMode, AudioOverlayVideoNoAudioTrackBehaviour, AudioWatermarkAudioMethod, AudioWatermarkAudioRobustness, AudioWatermarkAudioDensity, AudioWatermarkVideoMethod, AudioWatermarkVideoRobustness, AudioWatermarkVideoDensity, AudioToVideoAudioOutputResolution, AudioToVideoAudioImageFit, AudioToVideoAudioOutputFormat, VideoWatermarkVideoAnchor, VideoTextWatermarkVideoFontFamily, VideoTextWatermarkVideoWatermarkMode, VideoTextWatermarkVideoAnchor, SplitImageGifOutputFormat, SplitDocumentPdfMode, SplitAudioMode, SplitAudioPrecision, SplitVideoMode, SplitVideoPrecision, } from '@giveitsmaller/contracts/operations';
38
38
  export { archiveMetadata, audioOverlayMetadata, audioWatermarkMetadata, compressMetadata, convertMetadata, customLumaMetadata, imageWatermarkMetadata, mergeMetadata, textWatermarkMetadata, thumbnailMetadata, audioToVideoMetadata, videoWatermarkMetadata, videoTextWatermarkMetadata, splitMetadata, transformMetadata, } from '@giveitsmaller/contracts/operations';
39
39
  export type { ConvertOptions, ThumbnailOptions, TransformOptions, TextWatermarkOptions, WatermarkOptions, WatermarkOverlay, WatermarkAnchor, } from './ergonomic/option_types.js';
@@ -11,12 +11,28 @@ 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, GislAbortError,
15
- // FF2b / tywwynmN transport-level failure (mirrors PHP GislNetworkError);
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,
18
+ // FF2b / tywwynmN — off-envelope failure base (mirrors PHP GislNetworkError);
16
19
  // raised by the file-first HttpDownloader when an output URL cannot be read.
20
+ // t2qCrjdr: NEVER THROWN DIRECTLY any more — it is the hierarchy node the two
21
+ // subclasses below share, kept so existing `instanceof GislNetworkError`
22
+ // narrowing (including the SSE poll-fallback) is unchanged.
17
23
  GislNetworkError,
24
+ // t2qCrjdr — the split. One `retryable` could not be honest for both a DNS
25
+ // failure and a 404, so each case is now its own class with its own answer.
26
+ GislTransportError, GislDownloadHttpError,
27
+ // The request never left the client — never retryable. TS detects only what
28
+ // it can see BEFORE the call (an unparseable URL); PHP also classifies
29
+ // PSR-18's RequestExceptionInterface, which `fetch` gives no equivalent of.
30
+ GislRequestNotSentError,
18
31
  // T1 / wVU4xHx3 — local config-error tree (pre-I/O; sibling of GislApiError).
19
- GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError,
32
+ GislConfigError, GislMissingCredentialsError,
33
+ // VUozk5Bc — `streamEvents` on a client with no DECLARED stream host. The
34
+ // SDK refuses to derive `stream.*` from `api.*`; `run()` polls instead.
35
+ GislStreamHostNotDeclaredError, GislFeatureRequiresAuthError,
20
36
  // T3 / cuecCmb5 — merge-compose local validation errors.
21
37
  GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedError,
22
38
  // T6 / aDR1jnyZ — chain-cardinality validation (dormant until chain
package/dist/merge.d.ts CHANGED
@@ -103,6 +103,35 @@ 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.
127
+ *
128
+ * The contract CAN now express this — `per_class_availability` scopes an option to
129
+ * a processing class, vendored at v2.195.0 and pinned by
130
+ * `tests/unit/per-class-availability-conformance.test.ts`. That buys an honest 422
131
+ * from the API at CREATE rather than a job dying mid-execution; it does NOT become
132
+ * a client-side gate, because routing is still decided server-side and a duration
133
+ * heuristic here would be wrong at the boundary. Tracked by `zJN6XIi5`.
134
+ */
106
135
  readonly targetSize?: string | number;
107
136
  readonly transitionDuration?: number;
108
137
  readonly fps?: number;
package/dist/merge.js CHANGED
@@ -26,7 +26,7 @@
26
26
  * assets both fail fast so the caller saves bandwidth on typo'd composes.
27
27
  */
28
28
  import { uploadSource, jobOutputSource } from './types.js';
29
- import { GislConfigError, GislNetworkError, GislPerInputOptionsNotSupportedError, GislTimeoutError, GislUndeclaredAssetError, GislUnusedAssetError, SseEndedWithoutTerminal, } from './errors.js';
29
+ import { GislConfigError, GislNetworkError, GislPerInputOptionsNotSupportedError, GislTimeoutError, GislUndeclaredAssetError, GislUnusedAssetError, GislStreamHostNotDeclaredError, SseEndedWithoutTerminal, } from './errors.js';
30
30
  import { _cappedProbeTimeoutMs, _checkAborted, _consumeSseToTerminal, _detectCompressMedia, _parseMaxWait, _pollToTerminal, _projectResult, } from './builder.js';
31
31
  import { Handle } from './handle.js';
32
32
  /**
@@ -445,7 +445,17 @@ export class MergeBuilder {
445
445
  // TDqmkWpX: poll-fallback ONLY on a clean SSE stream-end or a typed
446
446
  // transport error; rethrow everything else (timeout, abort, API, an
447
447
  // onProgress callback throw, anything unexpected) so it isn't masked.
448
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
448
+ if (!(err instanceof SseEndedWithoutTerminal ||
449
+ err instanceof GislNetworkError ||
450
+ // VUozk5Bc: no stream host is DECLARED for this configuration (a
451
+ // configuration nothing declares; both named environments resolve as of
452
+ // contracts v2.195.0). That is not a failure to recover from,
453
+ // it is SSE being unavailable here, and polling is a working
454
+ // transport. Failing hard instead would strand every caller on a host
455
+ // nobody has declared yet. A DIRECT `streamEvents` caller still gets
456
+ // the hard error — they asked for the stream specifically; a `run()`
457
+ // caller asked for a result.
458
+ err instanceof GislStreamHostNotDeclaredError)) {
449
459
  throw err;
450
460
  }
451
461
  }
package/dist/sse.d.ts CHANGED
@@ -6,7 +6,29 @@ import type { GislSseEvent, GislSseParseFailure } from './types.js';
6
6
  * - Chunk boundary buffering (events split across chunks)
7
7
  * - Multi-line `data:` fields (concatenated with newlines)
8
8
  * - Comment lines (`:` prefix) used as keep-alives
9
- * - `retry:` field (ignored, SDK manages its own reconnection)
9
+ * - `id:` and `retry:` fields — IGNORED, and neither is surfaced on
10
+ * `GislSseEvent`
11
+ *
12
+ * 🔴 THIS SDK DOES NOT RECONNECT. It opens ONE stream and yields frames until
13
+ * the server ends it, the caller breaks, or the signal aborts. There is no
14
+ * retry loop, no backoff, and **no `Last-Event-ID` resumption** — so a dropped
15
+ * connection loses every event published while it was down, and the server
16
+ * cannot replay them.
17
+ *
18
+ * ⚠️ AN EARLIER VERSION OF THIS LINE READ "ignored, SDK manages its own
19
+ * reconnection", WHICH IS FALSE AND SAYS THE OPPOSITE OF THE TRUTH. A reader
20
+ * meeting it concluded retries were handled here. The poll-fallback in `run()`
21
+ * is a DIFFERENT TRANSPORT — it abandons the stream and polls
22
+ * `getWorkflowStatus` — not a reconnection, and it exists only on the
23
+ * ergonomic path. A direct `streamEvents` caller gets no recovery of any kind.
24
+ *
25
+ * ⇒ If you need to survive a drop, wrap this in your own loop AND reconcile
26
+ * the terminal state via `getWorkflowStatus` afterwards, because the gap is
27
+ * unrecoverable from the stream alone. See `docs/typescript/sse.md`.
28
+ *
29
+ * PHP and Python have carried this disclaimer since B2.2; TypeScript is the
30
+ * reference implementation both mirror and was the only one asserting the
31
+ * opposite (hub audit, 2026-08-29).
10
32
  *
11
33
  * `opts.signal` (optional): when it aborts, the underlying body reader is
12
34
  * cancelled. This is the ONLY way to promptly stop a stream parked on a
package/dist/sse.js CHANGED
@@ -5,7 +5,29 @@
5
5
  * - Chunk boundary buffering (events split across chunks)
6
6
  * - Multi-line `data:` fields (concatenated with newlines)
7
7
  * - Comment lines (`:` prefix) used as keep-alives
8
- * - `retry:` field (ignored, SDK manages its own reconnection)
8
+ * - `id:` and `retry:` fields — IGNORED, and neither is surfaced on
9
+ * `GislSseEvent`
10
+ *
11
+ * 🔴 THIS SDK DOES NOT RECONNECT. It opens ONE stream and yields frames until
12
+ * the server ends it, the caller breaks, or the signal aborts. There is no
13
+ * retry loop, no backoff, and **no `Last-Event-ID` resumption** — so a dropped
14
+ * connection loses every event published while it was down, and the server
15
+ * cannot replay them.
16
+ *
17
+ * ⚠️ AN EARLIER VERSION OF THIS LINE READ "ignored, SDK manages its own
18
+ * reconnection", WHICH IS FALSE AND SAYS THE OPPOSITE OF THE TRUTH. A reader
19
+ * meeting it concluded retries were handled here. The poll-fallback in `run()`
20
+ * is a DIFFERENT TRANSPORT — it abandons the stream and polls
21
+ * `getWorkflowStatus` — not a reconnection, and it exists only on the
22
+ * ergonomic path. A direct `streamEvents` caller gets no recovery of any kind.
23
+ *
24
+ * ⇒ If you need to survive a drop, wrap this in your own loop AND reconcile
25
+ * the terminal state via `getWorkflowStatus` afterwards, because the gap is
26
+ * unrecoverable from the stream alone. See `docs/typescript/sse.md`.
27
+ *
28
+ * PHP and Python have carried this disclaimer since B2.2; TypeScript is the
29
+ * reference implementation both mirror and was the only one asserting the
30
+ * opposite (hub audit, 2026-08-29).
9
31
  *
10
32
  * `opts.signal` (optional): when it aborts, the underlying body reader is
11
33
  * cancelled. This is the ONLY way to promptly stop a stream parked on a
@@ -105,7 +127,55 @@ export async function* parseSseStream(response, opts = {}) {
105
127
  continue;
106
128
  }
107
129
  if (line.startsWith(':')) {
108
- // Comment line (keep-alive), skip
130
+ // Comment line (keep-alive), skip.
131
+ //
132
+ // ⚠️ DO NOT "FIX" THIS INTO SURFACING COMMENT FRAMES WITHOUT READING
133
+ // THIS. Dropping them is CORRECT per the SSE spec — a comment carries
134
+ // no event — but it is also LOAD-BEARING FOR A CONSUMER, and that
135
+ // consumer is in another repo.
136
+ //
137
+ // The frontend does NOT close its SSE reader on the terminal event:
138
+ // `workflow_completed` patches status and breaks WITHOUT aborting.
139
+ // What actually closes the stream is an IDLE WATCHDOG at 20s. That
140
+ // watchdog only fires because heartbeats are never yielded here, so
141
+ // they never re-arm it — roughly 20s after the last real event the
142
+ // reader closes and the PHP worker is released.
143
+ //
144
+ // HEARTBEAT ~16s vs IDLE TIMEOUT 20s: a FOUR-SECOND MARGIN that holds
145
+ // only while the heartbeats are invisible. Surfacing comment frames —
146
+ // a completely reasonable change, since EventSource semantics treat
147
+ // keep-alive comments as liveness signals that legitimately reset
148
+ // timeouts — would CONTINUOUSLY re-arm the watchdog. The stream would
149
+ // never go idle, and every completed job left on screen would hold a
150
+ // PHP worker for the full 570s. That is worker exhaustion from
151
+ // ordinary users leaving tabs open.
152
+ //
153
+ // A ONE-LINE CHANGE HERE SILENTLY CONVERTS THE FRONTEND FROM BOUNDED
154
+ // TO UNBOUNDED, and nothing on either side would flag it.
155
+ //
156
+ // ⚠️ THIS NOTE IS PERMANENT. DO NOT DELETE IT WHEN THE FRONTEND
157
+ // ADDS AN EXPLICIT CLOSE-ON-TERMINAL. That fix removes the TERMINAL
158
+ // case and does NOT remove the dependency, because closing finished
159
+ // streams was never the watchdog's job — that was a side effect
160
+ // nobody knew about until 2026-08-12.
161
+ //
162
+ // THE WATCHDOG'S ACTUAL JOB IS DETECTING A STREAM THAT HAS GONE
163
+ // SILENT WHILE THE WORKFLOW IS STILL RUNNING, and resuming the
164
+ // fallback poll. That case survives every fix in flight, and it is
165
+ // not exotic: a long operation between progress events produces it
166
+ // exactly — heartbeats flowing, no data frames, job still running.
167
+ //
168
+ // If this parser surfaced comment frames, heartbeats at ~16s would
169
+ // re-arm the 20s watchdog FOREVER and STALL DETECTION WOULD NEVER
170
+ // FIRE AT ALL, on a stream that is genuinely stuck. The UI would go
171
+ // on believing a dead job is fine. That is worse than the worker
172
+ // leak: a leak is bounded by 570s, a missed stall is not bounded at
173
+ // all.
174
+ //
175
+ // The PHP SDK does the same thing at GislClient.php (`$line[0] === ':'`),
176
+ // verified 2026-08-12 — the two languages agree on this axis, and
177
+ // they must stay agreed: this property governs whether a stuck
178
+ // stream is EVER detected, so a divergence here is not cosmetic.
109
179
  continue;
110
180
  }
111
181
  const colonIndex = line.indexOf(':');
@@ -124,7 +194,10 @@ export async function* parseSseStream(response, opts = {}) {
124
194
  dataLines.push(fieldValue);
125
195
  break;
126
196
  case 'retry':
127
- // Ignored — SDK manages its own polling/reconnection
197
+ // Ignored — and NOT because something else honours it. This SDK
198
+ // never reconnects, so a server-suggested retry interval has no
199
+ // consumer here. (Was "SDK manages its own polling/reconnection",
200
+ // which claimed a behaviour that does not exist.)
128
201
  break;
129
202
  }
130
203
  }
package/dist/types.d.ts CHANGED
@@ -2,6 +2,24 @@ import type { OperationType, OperationsSchemaResponse, OperationCapability, Outp
2
2
  import type { JobInputV2RoleEnum, NotifyConfig } from '@giveitsmaller/contracts/openapi';
3
3
  export interface GislClientConfig {
4
4
  baseUrl: string;
5
+ /**
6
+ * Host for the **SSE event stream** (`streamEvents`). The stream is served
7
+ * from a SECOND public entry point, separate from `baseUrl`: the API host
8
+ * fronts an integration with no response-streaming mode.
9
+ *
10
+ * Setting this moves the stream and **nothing else** — uploads,
11
+ * workflow-create and downloads keep using `baseUrl`. That is the reason it
12
+ * exists as its own field rather than being expressed by overriding
13
+ * `baseUrl`, which moves every call.
14
+ *
15
+ * When omitted, `gisl.create()` resolves it from the `environment` against
16
+ * the contract-declared stream hosts. It is **never derived from `baseUrl`**
17
+ * — if nothing declares a stream host for your configuration, `streamEvents`
18
+ * throws `GislStreamHostNotDeclaredError` rather than silently reusing the
19
+ * API host, and `run()` falls back to polling. See
20
+ * `ENVIRONMENT_STREAM_ENDPOINTS`.
21
+ */
22
+ streamBaseUrl?: string;
5
23
  apiKey?: string;
6
24
  headers?: Record<string, string>;
7
25
  timeout?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giveitsmaller/sdk",
3
- "version": "0.21.0",
3
+ "version": "0.25.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.59.0"
34
+ "@giveitsmaller/contracts": "^0.69.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22",
@@ -47,5 +47,13 @@
47
47
  "test": "vitest run",
48
48
  "test:parity": "vitest run tests/parity",
49
49
  "parity:update": "UPDATE_PARITY_FIXTURES=1 vitest run tests/parity"
50
+ },
51
+ "homepage": "https://docs.giveitsmaller.com",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/giveitsmaller/typescript-sdk.git"
55
+ },
56
+ "bugs": {
57
+ "url": "https://github.com/giveitsmaller/typescript-sdk/issues"
50
58
  }
51
59
  }