@giveitsmaller/sdk 0.4.0 → 0.7.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.
Files changed (50) hide show
  1. package/dist/_audit.js +67 -0
  2. package/dist/builder.d.ts +406 -0
  3. package/dist/builder.js +706 -0
  4. package/dist/client.d.ts +96 -2
  5. package/dist/client.js +968 -33
  6. package/dist/credentials.d.ts +61 -0
  7. package/dist/credentials.js +200 -0
  8. package/dist/ergonomic/preset_resolver.d.ts +75 -0
  9. package/dist/ergonomic/preset_resolver.js +568 -0
  10. package/dist/ergonomic/presets/_translate.d.ts +11 -0
  11. package/dist/ergonomic/presets/_translate.js +35 -0
  12. package/dist/ergonomic/presets/audio_compress.d.ts +16 -0
  13. package/dist/ergonomic/presets/audio_compress.js +45 -0
  14. package/dist/ergonomic/presets/document_epub_compress.d.ts +14 -0
  15. package/dist/ergonomic/presets/document_epub_compress.js +34 -0
  16. package/dist/ergonomic/presets/document_odf_compress.d.ts +14 -0
  17. package/dist/ergonomic/presets/document_odf_compress.js +34 -0
  18. package/dist/ergonomic/presets/document_office_compress.d.ts +16 -0
  19. package/dist/ergonomic/presets/document_office_compress.js +40 -0
  20. package/dist/ergonomic/presets/document_pdf_compress.d.ts +14 -0
  21. package/dist/ergonomic/presets/document_pdf_compress.js +35 -0
  22. package/dist/ergonomic/presets/image_compress.d.ts +43 -0
  23. package/dist/ergonomic/presets/image_compress.js +95 -0
  24. package/dist/ergonomic/presets/index.d.ts +77 -0
  25. package/dist/ergonomic/presets/index.js +216 -0
  26. package/dist/ergonomic/presets/video_compress.d.ts +30 -0
  27. package/dist/ergonomic/presets/video_compress.js +83 -0
  28. package/dist/errors.d.ts +251 -1
  29. package/dist/errors.js +268 -0
  30. package/dist/generated/sdk_spec/enums.d.ts +195 -0
  31. package/dist/generated/sdk_spec/enums.js +127 -0
  32. package/dist/generated/sdk_spec/errors.d.ts +16 -0
  33. package/dist/generated/sdk_spec/errors.js +473 -0
  34. package/dist/generated/sdk_spec/index.d.ts +4 -0
  35. package/dist/generated/sdk_spec/index.js +7 -0
  36. package/dist/generated/sdk_spec/presets.d.ts +6 -0
  37. package/dist/generated/sdk_spec/presets.js +157 -0
  38. package/dist/generated/sdk_spec/version.d.ts +3 -0
  39. package/dist/generated/sdk_spec/version.js +6 -0
  40. package/dist/gisl.d.ts +112 -0
  41. package/dist/gisl.js +266 -0
  42. package/dist/index.d.ts +17 -7
  43. package/dist/index.js +33 -3
  44. package/dist/merge.d.ts +142 -0
  45. package/dist/merge.js +411 -0
  46. package/dist/sse.d.ts +20 -1
  47. package/dist/sse.js +62 -3
  48. package/dist/types.d.ts +144 -14
  49. package/dist/types.js +18 -0
  50. package/package.json +2 -2
package/dist/merge.js ADDED
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Merge-compose layer for the SDK ergonomic surface (T3 / cuecCmb5).
3
+ *
4
+ * `client.merge(...assets, options?)` returns a `MergeBuilder`. The builder
5
+ * separates WHAT (the asset set) from ORDER (the timeline):
6
+ *
7
+ * - `merge(a, b, c)` declares the asset set — each unique input is uploaded
8
+ * ONCE per run, even if referenced multiple times in the sequence.
9
+ * - `.sequence(...refs)` defines the play order. References may repeat
10
+ * freely; entries may be bare asset refs or `clip(ref, opts)` objects
11
+ * carrying per-position options.
12
+ * - No `.sequence(...)` => play in declared order, no transitions.
13
+ *
14
+ * Wire-truth boundaries (lowering.md §sequences):
15
+ * - Video merge per-input options: `transition`, `crossfadeDuration` only.
16
+ * - Audio merge per-input options: `transition`, `crossfadeDuration`,
17
+ * `gapDuration` only.
18
+ * - Image merge has NO per-input options today — `clip(ref)` is reuse/order
19
+ * only. Per-position transitions on image merges throw locally as
20
+ * `GislPerInputOptionsNotSupportedError`.
21
+ * - No per-clip `trimStart`/`trimEnd` today (contracts ticket iZzn5QrS
22
+ * tracks the fix). Workaround: pre-trim each clip via a chained
23
+ * `compress(file, trimStart, trimEnd)`.
24
+ *
25
+ * Local validation runs BEFORE any upload — undeclared refs and unused
26
+ * assets both fail fast so the caller saves bandwidth on typo'd composes.
27
+ */
28
+ import { uploadSource } from './types.js';
29
+ import { GislConfigError, GislPerInputOptionsNotSupportedError, GislTimeoutError, GislUndeclaredAssetError, GislUnusedAssetError, } from './errors.js';
30
+ import { _checkAborted, _consumeSseToTerminal, _parseMaxWait, _pollToTerminal, _projectResult, } from './builder.js';
31
+ /**
32
+ * Construct a path-asset. Bare-string arguments to `merge(...)` are
33
+ * implicitly wrapped via this helper.
34
+ */
35
+ export function asset(path) {
36
+ return { type: 'path', path };
37
+ }
38
+ /**
39
+ * Wrap an already-uploaded file_id as a merge asset. Use this when the
40
+ * SAME logical file should be referenced from multiple merge runs with
41
+ * guaranteed-single-upload semantics.
42
+ */
43
+ export function handle(fileId) {
44
+ return { type: 'handle', fileId };
45
+ }
46
+ /**
47
+ * Construct a clip entry for `.sequence(...)`. The asset MUST already
48
+ * be in the merge's declared asset set.
49
+ */
50
+ export function clip(ref, options = {}) {
51
+ return { type: 'clip', asset: ref, options };
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // MergeBuilder
55
+ // ---------------------------------------------------------------------------
56
+ /**
57
+ * Captures the (declared assets, options) for a merge. `.sequence(...)`
58
+ * pins the play order; without it, the declared order is used as-is
59
+ * with no per-input options.
60
+ *
61
+ * Local validation runs at `.run()`/`.submit()` time (BEFORE any upload)
62
+ * and throws one of `GislUndeclaredAssetError`, `GislUnusedAssetError`,
63
+ * or `GislPerInputOptionsNotSupportedError` if the compose is invalid.
64
+ */
65
+ export class MergeBuilder {
66
+ client;
67
+ assets;
68
+ opOptions;
69
+ sequenceEntries = null;
70
+ constructor(client, assets, opOptions) {
71
+ this.client = client;
72
+ this.assets = assets;
73
+ this.opOptions = opOptions;
74
+ }
75
+ /**
76
+ * Pin the merge play order. Each entry must reference an asset that
77
+ * was declared in the parent `merge(...)` call. Repeats are allowed
78
+ * and deduped on upload (one upload per unique declared asset).
79
+ */
80
+ sequence(...entries) {
81
+ this.sequenceEntries = entries;
82
+ return this;
83
+ }
84
+ async run(options) {
85
+ const deadline = Date.now() + _parseMaxWait(options.maxWait);
86
+ const signal = options.signal;
87
+ const onProgress = options.onProgress;
88
+ const useSSE = options.useSSE ?? true;
89
+ // 1. Validate locally BEFORE any upload.
90
+ const plan = this.planSequence();
91
+ // 2. Upload each unique asset exactly ONCE. Pass the deadline so the
92
+ // upload loop can abort mid-batch on a slow connection.
93
+ const uploadedByAssetId = await this.uploadUniqueAssets(plan.uniqueAssets, {
94
+ signal,
95
+ onProgress,
96
+ deadline,
97
+ });
98
+ _checkAborted(signal);
99
+ if (Date.now() >= deadline) {
100
+ throw new GislTimeoutError(`Upload(s) completed but maxWait elapsed before merge workflow could be created`);
101
+ }
102
+ // 3. Build the merge JobDefinitionPayload (multi-input).
103
+ const payload = this.buildPayload(plan, uploadedByAssetId);
104
+ const created = await this.client.createWorkflow(payload);
105
+ _checkAborted(signal);
106
+ // 4. Wait to terminal status.
107
+ const finalStatus = await this.awaitTerminal({
108
+ workflowId: created.workflowId,
109
+ deadline,
110
+ signal,
111
+ onProgress,
112
+ useSSE,
113
+ pollIntervalMs: options.pollIntervalMs,
114
+ });
115
+ // 5. Fetch downloads + project.
116
+ if (Date.now() >= deadline) {
117
+ throw new GislTimeoutError(`Merge workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
118
+ }
119
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
120
+ return _projectResult(finalStatus, downloads.downloads, this.opOptionsForResolved());
121
+ }
122
+ async submit(options) {
123
+ const plan = this.planSequence();
124
+ const uploadedByAssetId = await this.uploadUniqueAssets(plan.uniqueAssets, {});
125
+ const payload = this.buildPayload(plan, uploadedByAssetId);
126
+ payload.callback_url = options.webhook;
127
+ const created = await this.client.createWorkflow(payload);
128
+ const handle = {
129
+ workflowId: created.workflowId,
130
+ ...(created.webhookSecret != null ? { webhookSecret: created.webhookSecret } : {}),
131
+ };
132
+ return handle;
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ /**
136
+ * Resolve the declared assets + sequence (or fall back to declared order),
137
+ * dedupe by identity, and run the local validators. The returned plan
138
+ * carries the SEQUENCE (positional entries) + the UNIQUE assets to upload.
139
+ */
140
+ planSequence() {
141
+ const declaredIds = this.assets.map(assetIdentity);
142
+ const declaredSet = new Map();
143
+ for (let i = 0; i < this.assets.length; i += 1) {
144
+ const id = declaredIds[i];
145
+ if (!declaredSet.has(id))
146
+ declaredSet.set(id, this.assets[i]);
147
+ }
148
+ // Use the explicit sequence if set; otherwise the declared order as-is.
149
+ const rawEntries = this.sequenceEntries ?? this.assets.map((a) => a);
150
+ // Validate per-entry: undeclared ref + image-merge-clip-with-opts.
151
+ const mediaKind = this.inferMediaKind();
152
+ const positions = [];
153
+ const refIds = new Set();
154
+ for (const entry of rawEntries) {
155
+ const isClip = entry !== null && typeof entry === 'object' && 'type' in entry && entry.type === 'clip';
156
+ const assetRef = isClip ? entry.asset : entry;
157
+ const id = assetIdentity(assetRef);
158
+ if (!declaredSet.has(id)) {
159
+ throw new GislUndeclaredAssetError(id, Array.from(declaredSet.keys()));
160
+ }
161
+ refIds.add(id);
162
+ if (isClip) {
163
+ const opts = entry.options;
164
+ const hasOpts = opts.transition !== undefined ||
165
+ opts.crossfadeDuration !== undefined ||
166
+ opts.gapDuration !== undefined;
167
+ if (hasOpts && mediaKind === 'image') {
168
+ throw new GislPerInputOptionsNotSupportedError('image');
169
+ }
170
+ positions.push({ assetId: id, options: opts });
171
+ }
172
+ else {
173
+ positions.push({ assetId: id, options: {} });
174
+ }
175
+ }
176
+ // Unused-asset check.
177
+ if (this.sequenceEntries !== null && this.opOptions.allowUnusedAssets !== true) {
178
+ const unused = Array.from(declaredSet.keys()).filter((id) => !refIds.has(id));
179
+ if (unused.length > 0)
180
+ throw new GislUnusedAssetError(unused);
181
+ }
182
+ // Codex r1 medium edb1bb641d81 — when an explicit sequence is set,
183
+ // restrict the upload set to only the referenced assets. This prevents
184
+ // wasted uploads of declared-but-unsequenced assets (e.g. when the
185
+ // user passes allowUnusedAssets: true).
186
+ const uploadSet = this.sequenceEntries === null
187
+ ? declaredSet
188
+ : new Map(Array.from(declaredSet.entries()).filter(([id]) => refIds.has(id)));
189
+ // Codex r1 medium 5c86b67c979b — enforce merge schema input bounds
190
+ // (min_inputs: 2, max_inputs: 10 per generated/typescript/operations/merge.ts).
191
+ // Validate sequence position count, NOT unique-asset count: the merge job
192
+ // sends N inputs where N = position count (repeats included).
193
+ if (positions.length < 2) {
194
+ throw new GislConfigError(`merge requires at least 2 inputs (got ${positions.length}). Declare more assets or check the sequence.`);
195
+ }
196
+ if (positions.length > 10) {
197
+ throw new GislConfigError(`merge accepts at most 10 inputs (got ${positions.length}). Reduce the sequence or split the merge.`);
198
+ }
199
+ return { mediaKind, positions, uniqueAssets: uploadSet };
200
+ }
201
+ inferMediaKind() {
202
+ if (this.opOptions.mediaKind !== undefined)
203
+ return this.opOptions.mediaKind;
204
+ const first = this.assets[0];
205
+ if (first === undefined)
206
+ return 'video';
207
+ if (first.type === 'path' && typeof first.path === 'string') {
208
+ const lower = first.path.toLowerCase();
209
+ if (/\.(jpe?g|png|webp|avif|gif|heic|tiff?)$/.test(lower))
210
+ return 'image';
211
+ if (/\.(mp3|wav|flac|aac|ogg|m4a)$/.test(lower))
212
+ return 'audio';
213
+ return 'video';
214
+ }
215
+ if (first.type === 'path' && first.path instanceof Blob) {
216
+ if (first.path.type.startsWith('image/'))
217
+ return 'image';
218
+ if (first.path.type.startsWith('audio/'))
219
+ return 'audio';
220
+ return 'video';
221
+ }
222
+ return 'video';
223
+ }
224
+ async uploadUniqueAssets(uniqueAssets, opts) {
225
+ const uploaded = new Map();
226
+ for (const [id, a] of uniqueAssets) {
227
+ // Codex r1 medium 797b4113431f — check the deadline between uploads
228
+ // so a multi-file merge doesn't keep uploading past `maxWait`.
229
+ if (opts.deadline !== undefined && Date.now() >= opts.deadline) {
230
+ throw new GislTimeoutError(`maxWait elapsed mid-upload (after ${uploaded.size} of ${uniqueAssets.size} merge assets)`);
231
+ }
232
+ if (a.type === 'handle') {
233
+ uploaded.set(id, a.fileId);
234
+ continue;
235
+ }
236
+ const uploadOpts = {};
237
+ if (opts.signal !== undefined)
238
+ uploadOpts.signal = opts.signal;
239
+ if (opts.onProgress !== undefined) {
240
+ uploadOpts.onProgress = (uploadedBytes, totalBytes) => {
241
+ opts.onProgress?.({ phase: 'upload', uploadedBytes, totalBytes });
242
+ };
243
+ }
244
+ const resp = await this.client.uploadFile(a.path, uploadOpts);
245
+ uploaded.set(id, resp.fileId);
246
+ }
247
+ return uploaded;
248
+ }
249
+ buildPayload(plan, uploadedByAssetId) {
250
+ const inputs = plan.positions.map((pos) => {
251
+ const fileId = uploadedByAssetId.get(pos.assetId);
252
+ if (fileId === undefined) {
253
+ // Defensive — planSequence should have rejected this.
254
+ throw new Error(`Asset '${pos.assetId}' was never uploaded — internal builder bug`);
255
+ }
256
+ // Codex r1 HIGH 502c6bf232c2 — per_input_options goes on EACH
257
+ // JobInputV2Payload (per-input entry), NOT on operations[0].options.
258
+ // Skip emission for image merges (planSequence already rejects opts
259
+ // on image-merge clips). Project per ClipOptions per media kind
260
+ // (codex r1 medium 128404fa16a9 — gapDuration is audio-only).
261
+ const wireOpts = plan.mediaKind === 'image'
262
+ ? {}
263
+ : wirePerInputOptions(pos.options, plan.mediaKind);
264
+ const input = { source: uploadSource(fileId) };
265
+ if (Object.keys(wireOpts).length > 0) {
266
+ input.per_input_options = wireOpts;
267
+ }
268
+ return input;
269
+ });
270
+ // Merge-level options (excluding the SDK-side mediaKind/allowUnusedAssets).
271
+ const mergeOpts = wireMergeOptions(this.opOptions, plan.mediaKind);
272
+ const job = {
273
+ id: 'merge',
274
+ inputs,
275
+ operations: [{ type: 'merge', options: mergeOpts }],
276
+ };
277
+ return { jobs: [job] };
278
+ }
279
+ opOptionsForResolved() {
280
+ // Strip the SDK-only fields before exposing on resolvedOptions.applied.
281
+ const { mediaKind: _m, allowUnusedAssets: _a, ...rest } = this.opOptions;
282
+ void _m;
283
+ void _a;
284
+ return { ...rest };
285
+ }
286
+ async awaitTerminal(args) {
287
+ if (args.useSSE) {
288
+ try {
289
+ return await _consumeSseToTerminal(this.client, args);
290
+ }
291
+ catch (err) {
292
+ if (err instanceof GislTimeoutError)
293
+ throw err;
294
+ if (err instanceof DOMException && err.name === 'AbortError')
295
+ throw err;
296
+ }
297
+ }
298
+ return await _pollToTerminal(this.client, args);
299
+ }
300
+ }
301
+ /**
302
+ * Per-Blob identity tokens — referential dedupe via WeakMap. Two distinct
303
+ * Blob objects with the same size + MIME would otherwise hash to the
304
+ * SAME identity (silent data loss; code-reviewer P1 conf 8). Reference
305
+ * identity guarantees same-Blob = same-upload and different-Blob = two
306
+ * uploads, irrespective of content sniffing.
307
+ */
308
+ const _blobTokens = new WeakMap();
309
+ let _blobCounter = 0;
310
+ /**
311
+ * Asset identity for dedupe. Handles use their fileId; paths use a
312
+ * trim+trailing-separator-strip normalised string (NOT case-folded —
313
+ * case-insensitive dedupe would silently merge `A.mp4` and `a.mp4` on a
314
+ * case-sensitive filesystem; code-reviewer P1 conf 7). Blobs use
315
+ * referential identity via a WeakMap-backed token. Bare-string path
316
+ * dedupe is best-effort — use `handle()` for guaranteed reuse.
317
+ */
318
+ function assetIdentity(a) {
319
+ if (a.type === 'handle')
320
+ return `handle:${a.fileId}`;
321
+ if (a.path instanceof Blob) {
322
+ let token = _blobTokens.get(a.path);
323
+ if (token === undefined) {
324
+ _blobCounter += 1;
325
+ token = `${_blobCounter}`;
326
+ _blobTokens.set(a.path, token);
327
+ }
328
+ return `blob:${token}`;
329
+ }
330
+ // Codex r2 medium bb500566a683 — dedupe by the EXACT caller-provided
331
+ // string. Previous trim+trailing-slash-strip would collapse
332
+ // `'clip.mp4'` and `'clip.mp4 '` into one upload while uploadFile later
333
+ // received the original string. Exact-string dedupe = upload identity
334
+ // matches dedupe identity. Best-effort = "two identical strings dedupe;
335
+ // anything else is a separate upload" — predictable.
336
+ return `path:${a.path}`;
337
+ }
338
+ function wireMergeOptions(opts, mediaKind) {
339
+ const out = {};
340
+ if (opts.transition !== undefined)
341
+ out.transition = opts.transition;
342
+ if (opts.crossfadeDuration !== undefined)
343
+ out.crossfade_duration = opts.crossfadeDuration;
344
+ // Codex r2 medium ab2422e56ea0 — merge-level `gap_duration` is on
345
+ // MergeAudioOptions only (not MergeVideoOptions or MergeImageOptions).
346
+ // Drop it for non-audio merges instead of shipping an invalid payload.
347
+ if (opts.gapDuration !== undefined && mediaKind === 'audio')
348
+ out.gap_duration = opts.gapDuration;
349
+ if (opts.normalizeAudio !== undefined)
350
+ out.normalize_audio = opts.normalizeAudio;
351
+ if (opts.codec !== undefined)
352
+ out.codec = opts.codec;
353
+ if (opts.crf !== undefined)
354
+ out.crf = opts.crf;
355
+ if (opts.preset !== undefined)
356
+ out.preset = opts.preset;
357
+ if (opts.targetSize !== undefined) {
358
+ out.target_size_bytes = typeof opts.targetSize === 'number'
359
+ ? opts.targetSize
360
+ : parseSizeString(opts.targetSize);
361
+ out.encoding_mode = 'target_size';
362
+ }
363
+ if (opts.transitionDuration !== undefined)
364
+ out.transition_duration = opts.transitionDuration;
365
+ if (opts.fps !== undefined)
366
+ out.fps = opts.fps;
367
+ if (opts.durationPerImage !== undefined)
368
+ out.duration_per_image = opts.durationPerImage;
369
+ if (opts.loopCount !== undefined)
370
+ out.loop_count = opts.loopCount;
371
+ if (opts.output !== undefined)
372
+ out.output_type = opts.output;
373
+ if (opts.outputType !== undefined)
374
+ out.output_type = opts.outputType;
375
+ if (opts.videoFormat !== undefined)
376
+ out.video_format = opts.videoFormat;
377
+ return out;
378
+ }
379
+ /**
380
+ * Project a ClipOptions into the wire-shape per_input_options object.
381
+ * Codex r1 medium 128404fa16a9 — `gap_duration` is on AUDIO per-input
382
+ * only (`MergeAudioPerInputOptions`), NOT video. Splitting by mediaKind
383
+ * here keeps the wire payload honest and prevents the server from
384
+ * silently rejecting/ignoring an out-of-spec field.
385
+ */
386
+ function wirePerInputOptions(opts, mediaKind) {
387
+ const out = {};
388
+ if (opts.transition !== undefined)
389
+ out.transition = opts.transition;
390
+ if (opts.crossfadeDuration !== undefined)
391
+ out.crossfade_duration = opts.crossfadeDuration;
392
+ if (opts.gapDuration !== undefined && mediaKind === 'audio') {
393
+ out.gap_duration = opts.gapDuration;
394
+ }
395
+ return out;
396
+ }
397
+ function parseSizeString(s) {
398
+ const m = /^(\d+(?:\.\d+)?)\s*(KB|MB|GB|B)?$/i.exec(s.trim());
399
+ if (m === null)
400
+ throw new TypeError(`Invalid targetSize string '${s}'`);
401
+ const n = Number(m[1]);
402
+ const unit = (m[2] ?? 'B').toUpperCase();
403
+ switch (unit) {
404
+ case 'B': return Math.round(n);
405
+ case 'KB': return Math.round(n * 1_000);
406
+ case 'MB': return Math.round(n * 1_000_000);
407
+ case 'GB': return Math.round(n * 1_000_000_000);
408
+ /* istanbul ignore next */
409
+ default: throw new TypeError(`Unknown size unit '${unit}'`);
410
+ }
411
+ }
package/dist/sse.d.ts CHANGED
@@ -7,5 +7,24 @@ import type { GislSseEvent } from './types.js';
7
7
  * - Multi-line `data:` fields (concatenated with newlines)
8
8
  * - Comment lines (`:` prefix) used as keep-alives
9
9
  * - `retry:` field (ignored, SDK manages its own reconnection)
10
+ *
11
+ * `opts.signal` (optional): when it aborts, the underlying body reader is
12
+ * cancelled. This is the ONLY way to promptly stop a stream parked on a
13
+ * quiet socket: `reader.read()` is suspended, so the generator's `finally`
14
+ * cannot run until that read settles — calling `reader.cancel()` from the
15
+ * abort listener settles it (`{ done: true }`) and runs the stream's cancel
16
+ * algorithm, freeing the connection. (MDN/TC39: an async generator's
17
+ * `return()` is itself unreachable while suspended at `await`; cancellation
18
+ * must be driven externally via an AbortSignal.) `GislClient.streamEvents`
19
+ * owns the controller and wires `return()`/`throw()` → `abort()`.
20
+ *
21
+ * By-design limitation: when called WITHOUT `opts.signal`, there is no
22
+ * cancellation path while `reader.read()` is suspended — a consumer that
23
+ * `break`s / `gen.return()`s on a quiet socket stays stuck until the
24
+ * server sends data or closes (an inherent JS async-generator constraint,
25
+ * not a defect). Pass `opts.signal`, or prefer `GislClient.streamEvents`
26
+ * (which always wires one), whenever early termination must be prompt.
10
27
  */
11
- export declare function parseSseStream(response: Response): AsyncGenerator<GislSseEvent>;
28
+ export declare function parseSseStream(response: Response, opts?: {
29
+ signal?: AbortSignal;
30
+ }): AsyncGenerator<GislSseEvent>;
package/dist/sse.js CHANGED
@@ -6,13 +6,51 @@
6
6
  * - Multi-line `data:` fields (concatenated with newlines)
7
7
  * - Comment lines (`:` prefix) used as keep-alives
8
8
  * - `retry:` field (ignored, SDK manages its own reconnection)
9
+ *
10
+ * `opts.signal` (optional): when it aborts, the underlying body reader is
11
+ * cancelled. This is the ONLY way to promptly stop a stream parked on a
12
+ * quiet socket: `reader.read()` is suspended, so the generator's `finally`
13
+ * cannot run until that read settles — calling `reader.cancel()` from the
14
+ * abort listener settles it (`{ done: true }`) and runs the stream's cancel
15
+ * algorithm, freeing the connection. (MDN/TC39: an async generator's
16
+ * `return()` is itself unreachable while suspended at `await`; cancellation
17
+ * must be driven externally via an AbortSignal.) `GislClient.streamEvents`
18
+ * owns the controller and wires `return()`/`throw()` → `abort()`.
19
+ *
20
+ * By-design limitation: when called WITHOUT `opts.signal`, there is no
21
+ * cancellation path while `reader.read()` is suspended — a consumer that
22
+ * `break`s / `gen.return()`s on a quiet socket stays stuck until the
23
+ * server sends data or closes (an inherent JS async-generator constraint,
24
+ * not a defect). Pass `opts.signal`, or prefer `GislClient.streamEvents`
25
+ * (which always wires one), whenever early termination must be prompt.
9
26
  */
10
- export async function* parseSseStream(response) {
27
+ export async function* parseSseStream(response, opts = {}) {
11
28
  const body = response.body;
12
29
  if (!body) {
13
30
  return;
14
31
  }
32
+ const signal = opts.signal;
33
+ if (signal?.aborted) {
34
+ // Pre-aborted: the body is still unlocked (no reader yet), so cancel it
35
+ // directly to free the connection, then yield nothing.
36
+ await body.cancel().catch(() => { });
37
+ return;
38
+ }
15
39
  const reader = body.getReader();
40
+ // `reader.cancel()` is valid while the reader holds the lock (unlike
41
+ // `body.cancel()`, which throws "Cannot cancel a locked stream"). It
42
+ // settles the in-flight `reader.read()` with `{ done: true }` and runs
43
+ // the stream's cancel algorithm. We track `aborted` so the post-loop
44
+ // trailing flush below does NOT emit a partial, never-terminated event
45
+ // once the consumer has abandoned the stream (the cancelled read looks
46
+ // exactly like a clean EOF — `{ done: true }` — but a buffered
47
+ // unterminated `data:` run after an abort is garbage, not an event).
48
+ let aborted = false;
49
+ const onAbort = () => {
50
+ aborted = true;
51
+ void reader.cancel().catch(() => { });
52
+ };
53
+ signal?.addEventListener('abort', onAbort, { once: true });
16
54
  const decoder = new TextDecoder();
17
55
  let buffer = '';
18
56
  let eventType = '';
@@ -28,6 +66,12 @@ export async function* parseSseStream(response) {
28
66
  // Keep the last (potentially incomplete) line in the buffer
29
67
  buffer = lines.pop() ?? '';
30
68
  for (const line of lines) {
69
+ // If a single read delivered multiple complete events and the
70
+ // consumer aborted after receiving an earlier one (we were
71
+ // suspended at `yield`), do NOT keep emitting the remaining
72
+ // buffered events on resume — stop processing this batch.
73
+ if (aborted)
74
+ break;
31
75
  if (line === '') {
32
76
  // Empty line = end of event
33
77
  if (dataLines.length > 0) {
@@ -73,8 +117,12 @@ export async function* parseSseStream(response) {
73
117
  }
74
118
  }
75
119
  }
76
- // Flush any remaining buffered event
77
- if (dataLines.length > 0) {
120
+ // Flush any remaining buffered event — but ONLY on a genuine
121
+ // end-of-stream (server closed without a final blank line). After an
122
+ // abort, the cancelled read also surfaces as `{ done: true }`, yet a
123
+ // partial unterminated `data:` buffer is not a real event and must
124
+ // not be yielded once the consumer has abandoned the stream.
125
+ if (!aborted && dataLines.length > 0) {
78
126
  const rawData = dataLines.join('\n');
79
127
  let parsed;
80
128
  try {
@@ -90,6 +138,17 @@ export async function* parseSseStream(response) {
90
138
  }
91
139
  }
92
140
  finally {
141
+ signal?.removeEventListener('abort', onAbort);
142
+ // Cancel the body so the underlying HTTP connection is released on
143
+ // EVERY exit path (early `return()`/abort AND normal completion) — a
144
+ // bare `releaseLock()` leaves the socket open until GC. Cancelling an
145
+ // already-closed/cancelled stream is a harmless no-op that resolves.
146
+ try {
147
+ await reader.cancel();
148
+ }
149
+ catch {
150
+ /* stream already errored/closed — nothing to release */
151
+ }
93
152
  reader.releaseLock();
94
153
  }
95
154
  }