@ubercode/multipart-stream 0.1.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.
@@ -0,0 +1,785 @@
1
+ import { Readable } from 'node:stream';
2
+
3
+ /**
4
+ * Public error classes (FR-019, NFR-DR-S-001, NFR-DR-S-004, NFR-DR-S-012,
5
+ * NFR-DR-D-007).
6
+ *
7
+ * Every class:
8
+ * - extends `Error`
9
+ * - sets `this.name` literally to its class-name string in the constructor
10
+ * (NFR-DR-D-007 — survives minification AND is the documented fallback
11
+ * when `instanceof` returns false across the ESM/CJS module-format
12
+ * boundary)
13
+ * - supports `cause` plumbing via `super(message, options)`
14
+ * - exposes typed structured property fields for caller-side branching
15
+ *
16
+ * The classes are runtime values (NFR-012); consumers may import them as
17
+ * values for `instanceof` checks AND as types in signatures.
18
+ */
19
+ /**
20
+ * Thrown when no source-stream bytes arrive for `idleTimeoutMs` consecutive
21
+ * milliseconds. The source has been destroyed and all listeners removed by
22
+ * the time this surfaces.
23
+ *
24
+ * @example
25
+ * try {
26
+ * await fetchAndHandleMultipart(url, { idleTimeoutMs: 5000, totalTimeoutMs: 60_000, parser });
27
+ * } catch (err) {
28
+ * if (err instanceof MultipartIdleTimeoutError) {
29
+ * metrics.increment('multipart.idle_timeout', { ms: err.idleTimeoutMs });
30
+ * }
31
+ * throw err;
32
+ * }
33
+ */
34
+ declare class MultipartIdleTimeoutError extends Error {
35
+ /** Stable cross-format discriminator (NFR-DR-D-007). */
36
+ readonly name = "MultipartIdleTimeoutError";
37
+ /** The configured idle window (ms) that elapsed without source activity. */
38
+ readonly idleTimeoutMs: number;
39
+ /**
40
+ * @param idleTimeoutMs - The configured idle window in ms.
41
+ * @param options - Optional `{ cause }` for wrapping a lower-level error.
42
+ */
43
+ constructor(idleTimeoutMs: number, options?: ErrorOptions);
44
+ }
45
+ /**
46
+ * Thrown when the total wallclock budget (`totalTimeoutMs`) elapses,
47
+ * regardless of source activity.
48
+ *
49
+ * @example
50
+ * if (err instanceof MultipartTotalTimeoutError) {
51
+ * metrics.increment('multipart.total_timeout');
52
+ * }
53
+ */
54
+ declare class MultipartTotalTimeoutError extends Error {
55
+ readonly name = "MultipartTotalTimeoutError";
56
+ /** The configured total window (ms) that elapsed. */
57
+ readonly totalTimeoutMs: number;
58
+ /**
59
+ * @param totalTimeoutMs - The configured total budget in ms.
60
+ * @param options - Optional `{ cause }`.
61
+ */
62
+ constructor(totalTimeoutMs: number, options?: ErrorOptions);
63
+ }
64
+ /**
65
+ * Thrown when the caller-provided `AbortSignal` fires (FR-009), or is already
66
+ * aborted at call time. `reason` carries the signal's `reason` verbatim, or
67
+ * is `undefined` if the signal had no reason. The library NEVER synthesizes
68
+ * a server-derived reason (F-S-006).
69
+ *
70
+ * @example
71
+ * const ctrl = new AbortController();
72
+ * setTimeout(() => ctrl.abort(new Error('user cancelled')), 5000);
73
+ * try {
74
+ * await fetchAndHandleMultipart(url, { signal: ctrl.signal, idleTimeoutMs: 5000, totalTimeoutMs: 30000, parser });
75
+ * } catch (err) {
76
+ * if (err instanceof MultipartAbortError) console.warn('aborted because:', err.reason);
77
+ * }
78
+ */
79
+ declare class MultipartAbortError extends Error {
80
+ readonly name = "MultipartAbortError";
81
+ /**
82
+ * The signal's `reason` if the caller supplied one, else `undefined`. Per
83
+ * F-S-006 the library never synthesizes a reason that embeds server bytes.
84
+ */
85
+ readonly reason?: unknown;
86
+ /**
87
+ * @param reason - Optional caller-supplied abort reason.
88
+ * @param options - Optional `{ cause }`.
89
+ */
90
+ constructor(reason?: unknown, options?: ErrorOptions);
91
+ }
92
+ /**
93
+ * Thrown when the source stream ends without dicer observing the closing
94
+ * multipart boundary (FR-022) — typically a mid-flight server hangup or
95
+ * transport-layer cut. Cleanup per FR-010 still runs.
96
+ *
97
+ * @example
98
+ * if (err instanceof MultipartTruncatedError) {
99
+ * retryQueue.enqueue({ url, bytesReceived: err.bytesReceived });
100
+ * }
101
+ */
102
+ declare class MultipartTruncatedError extends Error {
103
+ readonly name = "MultipartTruncatedError";
104
+ /** Total bytes pulled from the source before it ended prematurely. */
105
+ readonly bytesReceived: number;
106
+ /**
107
+ * @param bytesReceived - Cumulative bytes received before the source ended.
108
+ * @param options - Optional `{ cause }`.
109
+ */
110
+ constructor(bytesReceived: number, options?: ErrorOptions);
111
+ }
112
+ /**
113
+ * Info-bag for {@link MultipartPartTooLargeError}.
114
+ */
115
+ interface MultipartPartTooLargeInfo {
116
+ /** The configured `maxPartBytes` cap that was exceeded. */
117
+ readonly maxPartBytes: number;
118
+ /** Zero-based ordinal of the offending part. */
119
+ readonly partIndex: number;
120
+ /** Bytes observed at the moment the cap tripped. */
121
+ readonly bytesReceived: number;
122
+ }
123
+ /**
124
+ * Thrown when a part body's accumulated bytes exceed `maxPartBytes`
125
+ * (NFR-DR-S-001). The offending part body is destroyed and full FR-010
126
+ * cleanup runs before this surfaces.
127
+ *
128
+ * @example
129
+ * if (err instanceof MultipartPartTooLargeError) {
130
+ * metrics.increment('multipart.part_too_large', { partIndex: err.partIndex });
131
+ * }
132
+ */
133
+ declare class MultipartPartTooLargeError extends Error {
134
+ readonly name = "MultipartPartTooLargeError";
135
+ readonly maxPartBytes: number;
136
+ readonly partIndex: number;
137
+ readonly bytesReceived: number;
138
+ /**
139
+ * @param info - Structured trip info: `{ maxPartBytes, partIndex, bytesReceived }`.
140
+ * @param options - Optional `{ cause }`.
141
+ */
142
+ constructor(info: MultipartPartTooLargeInfo, options?: ErrorOptions);
143
+ }
144
+ /**
145
+ * Info-bag for {@link MultipartHeadersTooLargeError}.
146
+ */
147
+ interface MultipartHeadersTooLargeInfo {
148
+ /** Discriminator: which limit was hit. */
149
+ readonly limit: 'count' | 'bytes';
150
+ /** Zero-based ordinal of the offending part. */
151
+ readonly partIndex: number;
152
+ /** The configured cap. */
153
+ readonly cap: number;
154
+ /** Observed value at trip-time. */
155
+ readonly observed: number;
156
+ }
157
+ /**
158
+ * Thrown when a part has more headers than `maxHeadersPerPart` (default 100)
159
+ * OR its header block bytes exceed `maxHeaderBytesPerPart` (default 16 KiB)
160
+ * — NFR-DR-S-004.
161
+ *
162
+ * @example
163
+ * if (err instanceof MultipartHeadersTooLargeError) {
164
+ * log.warn({ limit: err.limit, observed: err.observed }, 'oversized part headers');
165
+ * }
166
+ */
167
+ declare class MultipartHeadersTooLargeError extends Error {
168
+ readonly name = "MultipartHeadersTooLargeError";
169
+ readonly limit: 'count' | 'bytes';
170
+ readonly partIndex: number;
171
+ readonly cap: number;
172
+ readonly observed: number;
173
+ /**
174
+ * @param info - Structured trip info: `{ limit, partIndex, cap, observed }`.
175
+ * @param options - Optional `{ cause }`.
176
+ */
177
+ constructor(info: MultipartHeadersTooLargeInfo, options?: ErrorOptions);
178
+ }
179
+ /**
180
+ * Info-bag for {@link MultipartTooManyPartsError}.
181
+ */
182
+ interface MultipartTooManyPartsInfo {
183
+ /** The configured `maxParts` cap that was exceeded. */
184
+ readonly maxParts: number;
185
+ /** Observed part count when the cap tripped (== `maxParts + 1`). */
186
+ readonly observed: number;
187
+ }
188
+ /**
189
+ * Thrown when the multipart envelope contains more parts than `maxParts`
190
+ * (default `10_000`) — NFR-DR-S-012.
191
+ *
192
+ * @example
193
+ * if (err instanceof MultipartTooManyPartsError) {
194
+ * log.warn({ cap: err.maxParts }, 'too many parts');
195
+ * }
196
+ */
197
+ declare class MultipartTooManyPartsError extends Error {
198
+ readonly name = "MultipartTooManyPartsError";
199
+ readonly maxParts: number;
200
+ readonly observed: number;
201
+ /**
202
+ * @param info - Structured trip info: `{ maxParts, observed }`.
203
+ * @param options - Optional `{ cause }`.
204
+ */
205
+ constructor(info: MultipartTooManyPartsInfo, options?: ErrorOptions);
206
+ }
207
+
208
+ /**
209
+ * `extractBoundary` — RFC 2046 boundary extractor (FR-016).
210
+ *
211
+ * Public utility. Pure function — no I/O, no allocations beyond the result
212
+ * and a single intermediate buffer for unescaping a quoted string.
213
+ *
214
+ * Implementation note (NFR-DR-S-011, T-080): this is a hand-written,
215
+ * non-backtracking tokenizer. A naive regex with an alternation between the
216
+ * quoted-string and bare-token forms is ReDoS-prone on adversarial input
217
+ * (megabytes of `\\\"` sequences); the deliberate single-pass tokenizer
218
+ * below runs in O(n) time on any input.
219
+ */
220
+ /**
221
+ * Parse the `boundary=` parameter out of an HTTP `Content-Type` header value.
222
+ *
223
+ * Handles RFC 2046 quoted-string and bare-token forms. The first `boundary=`
224
+ * occurrence wins (parameters are scanned left-to-right). Boundary parameter
225
+ * names are matched case-insensitively. The returned token is unquoted and
226
+ * has its backslash escapes resolved.
227
+ *
228
+ * @param contentTypeHeader - The full `Content-Type` header value, e.g.
229
+ * `multipart/related; boundary="weird;boundary"`. May be `null` or
230
+ * `undefined`; both are treated as missing-input errors.
231
+ * @returns The unquoted boundary token.
232
+ * @throws {Error} `multipart: Content-Type header is required to extract
233
+ * boundary` when the input is null, undefined, or empty.
234
+ * @throws {Error} `multipart: boundary parameter missing from Content-Type:
235
+ * <header>` when the header has no `boundary=` parameter.
236
+ * @throws {Error} `multipart: boundary parameter is empty in Content-Type:
237
+ * <header>` when the parameter is present but the value is empty.
238
+ *
239
+ * In every error path that embeds the input header, the embedded value is
240
+ * sanitized via the full sanitizer (truncate to 120 chars, control-char
241
+ * redact, ANSI redact, JSON.stringify per NFR-DR-S-006).
242
+ *
243
+ * @example
244
+ * extractBoundary('multipart/related; boundary=foo'); // 'foo'
245
+ *
246
+ * @example
247
+ * // Quoted-string form with embedded special chars (RFC 2046):
248
+ * extractBoundary('multipart/related; boundary="weird;boundary"');
249
+ * // 'weird;boundary'
250
+ *
251
+ * @example
252
+ * // Multiple parameters in any order; case-insensitive parameter name:
253
+ * extractBoundary('multipart/related; type="application/dicom"; BOUNDARY=BAR; charset=utf-8');
254
+ * // 'BAR'
255
+ */
256
+ declare function extractBoundary(contentTypeHeader: string | null | undefined): string;
257
+
258
+ /**
259
+ * Public type aliases re-exported from `src/index.ts`.
260
+ *
261
+ * Per NFR-DR-A-013, every optional input field on the option-bag interfaces
262
+ * (`ParseMultipartOptions`, `MultipartHandlerOptions<T>`) uses the explicit
263
+ * `field?: T | undefined` form (NOT bare `field?: T`). This lets callers
264
+ * spread-merge dynamically-built option records under
265
+ * `exactOptionalPropertyTypes: true` without TypeScript complaining about an
266
+ * `undefined` slot the schema does not accept.
267
+ *
268
+ * Read-only output fields on `StreamingMultipartPart` and the result struct
269
+ * use the `field?: T | undefined` form too for symmetry.
270
+ *
271
+ * Internal-only types (`src/internal/`) are NOT re-exported and may use the
272
+ * simpler `field?: T` form since callers never construct them.
273
+ */
274
+
275
+ /**
276
+ * One sub-part yielded by `parseMultipartRelated`. The body is a *streaming*
277
+ * Node `Readable` — callers MUST drain or destroy it; if they neither drain
278
+ * nor destroy, the library destroys it for them in the iterator's `finally`
279
+ * (FR-010).
280
+ *
281
+ * Headers are normalized to lowercase keys (consistent with Node `http`) and
282
+ * collapsed to single strings; convenience getters (`contentType`,
283
+ * `contentId`, `contentLength`) pre-compute the common ones.
284
+ */
285
+ interface StreamingMultipartPart {
286
+ /**
287
+ * Zero-based ordinal of this part within the multipart envelope, in the
288
+ * order dicer emits them.
289
+ */
290
+ readonly index: number;
291
+ /**
292
+ * The multipart boundary that delimits this envelope. Echoed onto every
293
+ * part for caller-side logging only — parsing has already consumed it.
294
+ */
295
+ readonly boundary: string;
296
+ /**
297
+ * Lowercased part headers as flat strings. Names are normalized to
298
+ * lowercase; values are the result of internal flattening over dicer's
299
+ * `Buffer | Buffer[] | Buffer[][]` shapes.
300
+ *
301
+ * Reads are `string | undefined` due to `noUncheckedIndexedAccess`.
302
+ */
303
+ readonly headers: Readonly<Record<string, string | undefined>>;
304
+ /**
305
+ * Raw header block bytes captured from dicer (concatenated).
306
+ *
307
+ * Surfaced for callers who need byte-exact framing for re-emission or
308
+ * signature verification.
309
+ */
310
+ readonly rawHeaders: Buffer;
311
+ /**
312
+ * Pre-extracted `content-type` header value, or `''` if absent.
313
+ */
314
+ readonly contentType: string;
315
+ /**
316
+ * Pre-extracted `content-id` header value (raw, with angle brackets if the
317
+ * sender included them), or `undefined` if absent.
318
+ */
319
+ readonly contentId?: string | undefined;
320
+ /**
321
+ * Pre-extracted `content-length` parsed via `parseInt(_, 10)`, or
322
+ * `undefined` if the header is absent or non-numeric. Note: dicer streams
323
+ * regardless — this value is informational, not a contract.
324
+ */
325
+ readonly contentLength?: number | undefined;
326
+ /**
327
+ * The streaming body of this part as a Node `Readable`. Backed directly by
328
+ * dicer's per-part stream — no intermediate buffering. Callers MUST consume
329
+ * (drain, pipe, or destroy) before requesting the next part; the library
330
+ * cannot pause dicer's state machine for them.
331
+ */
332
+ readonly body: Readable;
333
+ }
334
+ /**
335
+ * Caller-supplied per-part decision function. Receives a part, returns
336
+ * either a value of type `T` (collected into `MultipartFetchResult.parts`)
337
+ * or `undefined` ("skip this part, contribute nothing").
338
+ *
339
+ * The parser is responsible for either:
340
+ * - draining `part.body` (e.g. via `streamToString` / `streamToBuffer` /
341
+ * a pipe), OR
342
+ * - returning `undefined` without touching `part.body` (the library will
343
+ * drain it).
344
+ *
345
+ * If the parser throws (or its returned promise rejects), the operation
346
+ * rejects with that error and the source stream is destroyed (FR-014).
347
+ */
348
+ type PartParser<T> = (part: StreamingMultipartPart) => Promise<T | undefined>;
349
+ /**
350
+ * The resolved value of `fetchAndHandleMultipart` (FR-DR-A-029 — JC-1 shape:
351
+ * `{ parts, bytes, elapsedMs, status, headers }`; the previously-considered
352
+ * `response: Response` field is REMOVED because the body is consumed by the
353
+ * time the result resolves).
354
+ *
355
+ * Successful operations resolve with `parts` populated. Per-part parser
356
+ * failures reject the whole call (FR-014) — they are not bundled into this
357
+ * struct.
358
+ */
359
+ interface MultipartFetchResult<T> {
360
+ /**
361
+ * Array of values returned by the caller's `PartParser<T>`, in part order,
362
+ * filtered to drop `undefined`s.
363
+ */
364
+ readonly parts: readonly T[];
365
+ /**
366
+ * Total bytes pulled from the source stream (raw multipart envelope size,
367
+ * not sum of part body sizes).
368
+ */
369
+ readonly bytes: number;
370
+ /** Wall-clock duration from `fetchAndHandleMultipart` entry to resolution. */
371
+ readonly elapsedMs: number;
372
+ /**
373
+ * HTTP status code from the underlying `fetch` response, captured before
374
+ * the body was consumed.
375
+ */
376
+ readonly status: number;
377
+ /**
378
+ * The `Headers` object from the underlying `fetch` response, captured
379
+ * before the body was consumed.
380
+ */
381
+ readonly headers: Headers;
382
+ }
383
+ /**
384
+ * Snapshot passed to `onProgress`. All fields are computed at the moment of
385
+ * the call.
386
+ */
387
+ interface ProgressSnapshot {
388
+ /** Cumulative bytes received from the source stream so far. */
389
+ readonly bytes: number;
390
+ /** Wall-clock ms since the operation started. */
391
+ readonly elapsedMs: number;
392
+ /** Bytes per second over `[start, now]`; `0` when `elapsedMs === 0`. */
393
+ readonly rateBps: number;
394
+ }
395
+ /**
396
+ * Pluggable structured-logging shim (FR-018, JC-2). Event-style: the library
397
+ * calls the function with a single object describing the event.
398
+ *
399
+ * The library currently only emits events with `level: 'warn'`. Adding more
400
+ * levels in a future minor version is non-breaking because `level` is a
401
+ * union, not a positional argument.
402
+ *
403
+ * Per NFR-DR-S-008, `meta` NEVER contains raw chunk bytes. When the library
404
+ * logs an `Error` whose source is dicer or the source stream, it passes only
405
+ * an `errSummary: { name, message }` object with `message` truncated to <=
406
+ * 120 chars, control characters redacted, and the value JSON-stringified
407
+ * per NFR-DR-S-006.
408
+ *
409
+ * @example
410
+ * // Pino adapter:
411
+ * const logger: Logger = (event) => log[event.level](event.meta, event.msg);
412
+ *
413
+ * @example
414
+ * // Default (when omitted): falls back to `console.warn(msg, meta)`.
415
+ */
416
+ type Logger = (event: {
417
+ level: 'warn';
418
+ msg: string;
419
+ meta?: unknown;
420
+ }) => void;
421
+ /**
422
+ * Options accepted by `parseMultipartRelated`. `idleTimeoutMs` and
423
+ * `totalTimeoutMs` are REQUIRED on this entry point too (FR-006 / JC-3 —
424
+ * required-on-both kills the slow-loris vector when the function is used
425
+ * server-side on `req.body`).
426
+ */
427
+ interface ParseMultipartOptions {
428
+ /**
429
+ * REQUIRED. Idle timeout (ms). Resets on every chunk received from the
430
+ * source `Readable` (per-chunk `'data'` listener; FR-DR-A-025).
431
+ * Validated as a positive finite integer in the inclusive range
432
+ * `[1, 2_147_483_647]` (NFR-DR-S-009 — Node clamps `setTimeout` delays
433
+ * above `2^31 - 1`).
434
+ */
435
+ idleTimeoutMs: number;
436
+ /**
437
+ * REQUIRED. Total timeout (ms), measured from the call. Same validation
438
+ * rules as `idleTimeoutMs`.
439
+ */
440
+ totalTimeoutMs: number;
441
+ /**
442
+ * Explicit boundary for raw `Readable` inputs. REQUIRED when `input` is a
443
+ * Node `Readable` (no `Content-Type` to parse); IGNORED when `input` is a
444
+ * `Response` (boundary is extracted from `Content-Type`).
445
+ */
446
+ boundary?: string | undefined;
447
+ /**
448
+ * Caller's `AbortSignal`. Already-aborted at call time → synchronous
449
+ * `MultipartAbortError`. Aborted mid-stream → next yield rejects.
450
+ */
451
+ signal?: AbortSignal | undefined;
452
+ /**
453
+ * Progress callback (FR-013). Fires at least once per yielded part and
454
+ * once at completion. Caller exceptions are caught and routed through
455
+ * `logger`; the library guarantees parsing is not derailed by a faulty
456
+ * sink. Per FR-DR-A-025, `onProgress` does NOT drive idle-timer reset.
457
+ */
458
+ onProgress?: ((snap: ProgressSnapshot) => void) | undefined;
459
+ /**
460
+ * Pluggable structured-logging shim (FR-018, JC-2). Event-style. When
461
+ * omitted, internal warnings fall back to `console.warn(msg, meta)`.
462
+ */
463
+ logger?: Logger | undefined;
464
+ /**
465
+ * Per-part body-size cap (NFR-DR-S-001). Omit (default) for no cap. Must
466
+ * be a positive finite integer when set.
467
+ */
468
+ maxPartBytes?: number | undefined;
469
+ /**
470
+ * Maximum part count (NFR-DR-S-012). Defaults to `10_000` when omitted.
471
+ */
472
+ maxParts?: number | undefined;
473
+ /**
474
+ * Maximum number of distinct headers per part (NFR-DR-S-004). Defaults to
475
+ * `100` when omitted.
476
+ */
477
+ maxHeadersPerPart?: number | undefined;
478
+ /**
479
+ * Maximum total bytes across the header block of a single part
480
+ * (NFR-DR-S-004). Defaults to `16_384` (16 KiB).
481
+ */
482
+ maxHeaderBytesPerPart?: number | undefined;
483
+ }
484
+ /**
485
+ * Options accepted by `fetchAndHandleMultipart<T>`. Same shape as
486
+ * {@link ParseMultipartOptions} (forwarded down per FR-DR-A-026) plus the
487
+ * required `parser` callback and the optional `fetchInit` for the underlying
488
+ * `fetch` call.
489
+ */
490
+ interface MultipartHandlerOptions<T> {
491
+ /** REQUIRED. Per-part decision function. See {@link PartParser}. */
492
+ parser: PartParser<T>;
493
+ /** REQUIRED. Idle timeout (ms). See {@link ParseMultipartOptions.idleTimeoutMs}. */
494
+ idleTimeoutMs: number;
495
+ /** REQUIRED. Total timeout (ms). See {@link ParseMultipartOptions.totalTimeoutMs}. */
496
+ totalTimeoutMs: number;
497
+ /** Caller's `AbortSignal`. Same semantics as in {@link ParseMultipartOptions}. */
498
+ signal?: AbortSignal | undefined;
499
+ /** Progress callback. Same semantics as in {@link ParseMultipartOptions}. */
500
+ onProgress?: ((snap: ProgressSnapshot) => void) | undefined;
501
+ /** Logger. Same semantics as in {@link ParseMultipartOptions}. */
502
+ logger?: Logger | undefined;
503
+ /** Per-part body-size cap. Forwarded. (NFR-DR-S-001) */
504
+ maxPartBytes?: number | undefined;
505
+ /** Maximum part count. Forwarded. (NFR-DR-S-012) */
506
+ maxParts?: number | undefined;
507
+ /** Maximum headers per part. Forwarded. (NFR-DR-S-004) */
508
+ maxHeadersPerPart?: number | undefined;
509
+ /** Maximum header-block bytes per part. Forwarded. (NFR-DR-S-004) */
510
+ maxHeaderBytesPerPart?: number | undefined;
511
+ /**
512
+ * Optional `RequestInit` for the underlying `fetch` call. The library
513
+ * unconditionally REJECTS `signal` here (FR-024) — pass it via
514
+ * `options.signal` instead. The static `Omit<RequestInit, 'signal'>`
515
+ * surfaces the rule at compile time; the runtime check fires for callers
516
+ * who narrow with `as`.
517
+ */
518
+ fetchInit?: Omit<RequestInit, 'signal'> | undefined;
519
+ }
520
+
521
+ /**
522
+ * `fetchAndHandleMultipart` — Layer B (fetch orchestration). FR-005.
523
+ *
524
+ * The wrapper:
525
+ * 1. Validates the option bag synchronously: `parser` presence,
526
+ * `validatePositiveTimeout` on both timeouts, FR-024 `fetchInit.signal`
527
+ * ban, and FR-009 already-aborted short-circuit. ALL of these run
528
+ * BEFORE `fetch` is invoked — T-013 spies on `globalThis.fetch` and
529
+ * asserts it was never called when the caller's signal is pre-aborted.
530
+ * 2. Calls `fetch(url, { ...fetchInit, signal: options.signal })` —
531
+ * forwards the caller's `AbortSignal` to fetch directly so a network-
532
+ * time abort cancels the request before any bytes flow.
533
+ * 3. Validates the response Content-Type (FR-021) case-insensitively
534
+ * against `multipart/related` BEFORE constructing dicer. The offending
535
+ * Content-Type is sanitized via `truncateForErrorEmbed` per
536
+ * NFR-DR-S-006. T-035 asserts the dicer-activity harness sees zero
537
+ * Dicer instances on this path.
538
+ * 4. Captures `status` + `headers` BEFORE consuming the body
539
+ * (FR-DR-A-029 — the Response is gone after iteration).
540
+ * 5. Forwards `idleTimeoutMs` / `totalTimeoutMs` / `signal` / `onProgress`
541
+ * / `logger` / cap fields DOWN to `parseMultipartRelated`. Per
542
+ * FR-DR-A-026 timer ownership lives in Layer A; Layer B does NOT
543
+ * construct a TimerState.
544
+ * 6. Drives the for-await loop, awaits `options.parser(part)` per part,
545
+ * collects non-undefined returns into `parts`.
546
+ * 7. Resolves with `{ parts, bytes, elapsedMs, status, headers }` per
547
+ * FR-DR-A-029. The previously-considered `response: Response` field
548
+ * is REMOVED at the type level.
549
+ *
550
+ * Bytes-tracking strategy: the wrapper supplies its own `onProgress`
551
+ * callback to `parseMultipartRelated` regardless of whether the caller
552
+ * supplied one. The internal callback captures `lastBytes` and (when the
553
+ * caller supplied one) forwards the snapshot to the caller — wrapping the
554
+ * caller's call in a try/catch that routes via the resolved logger
555
+ * (FR-017 silent-catch replacement). After the loop ends, the wrapper
556
+ * fires ONE final completion-tick `onProgress` call (T-014 full
557
+ * assertion) with the final `bytes` / `elapsedMs` / `rateBps`.
558
+ */
559
+
560
+ /**
561
+ * Wrap `fetch` end-to-end: call, validate Content-Type, parse multipart,
562
+ * route each part through the caller's `parser`, and resolve with
563
+ * {@link MultipartFetchResult} (FR-DR-A-029 — `{ parts, bytes, elapsedMs,
564
+ * status, headers }`; NO `response` field).
565
+ *
566
+ * @param url - Forwarded to `fetch`. `URL` is supported for parity with
567
+ * `fetch` itself.
568
+ * @param options - {@link MultipartHandlerOptions}. `parser`,
569
+ * `idleTimeoutMs`, and `totalTimeoutMs` are REQUIRED (FR-005 / FR-006).
570
+ * The static `Omit<RequestInit, 'signal'>` on `options.fetchInit` enforces
571
+ * the FR-024 signal-ban at compile time; the runtime check below catches
572
+ * dynamic spreads / `as` consumers.
573
+ * @returns A `Promise<MultipartFetchResult<T>>` that resolves once every
574
+ * part has been processed.
575
+ *
576
+ * @throws {TypeError} `multipart: options.parser is required` when
577
+ * `options.parser` is missing or not a function.
578
+ * @throws {TypeError} from `validatePositiveTimeout` when either timeout
579
+ * is missing/invalid (FR-006 / NFR-DR-S-009 — message mentions Node's
580
+ * `setTimeout` clamping).
581
+ * @throws {Error} `multipart: pass signal via options.signal — fetchInit.signal
582
+ * is reserved for internal use` when `options.fetchInit.signal` is set
583
+ * (FR-024).
584
+ * @throws {MultipartAbortError} synchronously when `options.signal?.aborted`
585
+ * is `true` at call time (FR-009). `fetch` is NEVER invoked on this path
586
+ * — T-013 spies on `globalThis.fetch` to verify.
587
+ * @throws {Error} `multipart: response Content-Type is not multipart/related;
588
+ * got <actual>` when the response Content-Type doesn't start with
589
+ * `multipart/related` (case-insensitive) (FR-021). Dicer is NEVER
590
+ * constructed on this path — T-035 asserts via the dicer-activity
591
+ * harness.
592
+ * @throws Any error from `parseMultipartRelated` (idle/total timeout,
593
+ * abort mid-stream, truncation, source error, dicer error, cap overflow).
594
+ * @throws Any error from `options.parser`.
595
+ *
596
+ * @example
597
+ * const result = await fetchAndHandleMultipart(url, {
598
+ * idleTimeoutMs: 10_000,
599
+ * totalTimeoutMs: 60_000,
600
+ * parser: async (part) => streamToString(part.body),
601
+ * });
602
+ * console.log(result.parts.length, 'parts in', result.elapsedMs, 'ms');
603
+ */
604
+ declare function fetchAndHandleMultipart<T>(url: string | URL, options: MultipartHandlerOptions<T>): Promise<MultipartFetchResult<T>>;
605
+
606
+ /**
607
+ * `parseMultipartRelated` — Layer A (parser/dicer adapter). FR-001.
608
+ *
609
+ * Resource-cap enforcement (NFR-DR-S-001/004/012):
610
+ * - `maxPartBytes` (NFR-DR-S-001): each per-part body Readable gets a
611
+ * 'data' listener that increments a per-part byte counter; on overflow
612
+ * the listener pushes `MultipartPartTooLargeError` into the queue and
613
+ * destroys the offending part body. No default — when undefined, no
614
+ * cap is enforced.
615
+ * - `maxParts` (NFR-DR-S-012): the dicer 'part' counter is checked at
616
+ * each emit; on overflow, push `MultipartTooManyPartsError`. Default
617
+ * `10_000` when undefined.
618
+ * - `maxHeadersPerPart` + `maxHeaderBytesPerPart` (NFR-DR-S-004): on
619
+ * each per-part 'header' event, count headers and sum the byte length
620
+ * of `name + ': ' + value + '\r\n'` framing for every header line; on
621
+ * overflow, push `MultipartHeadersTooLargeError`. Defaults: count=100,
622
+ * bytes=16384 (16 KiB).
623
+ *
624
+ * Timer + abort machinery (FR-006/FR-007/FR-008/FR-009/FR-DR-A-025/
625
+ * FR-DR-A-026):
626
+ * - `validatePositiveTimeout` calls at the top of the generator enforce
627
+ * the FR-006 / JC-3 contract (both timeouts REQUIRED, both validated
628
+ * against `[1, 2^31-1]`).
629
+ * - `setupTimers(...)` constructs the composite abort signal aggregating
630
+ * idle, total, and caller-supplied AbortSignal — one source of truth
631
+ * for all three. The signal listens for any of those firing and pushes
632
+ * the right error class into the queue.
633
+ * - The per-chunk source `'data'` listener resets the idle timer
634
+ * (FR-DR-A-025 — onProgress is NOT used for this).
635
+ * - The cleanup function calls `timers.cleanup()` — same idempotent
636
+ * `cleaned` flag.
637
+ * - Already-aborted callers short-circuit on first `.next()` per FR-009.
638
+ *
639
+ * Cleanup contract: the `finally` cleanup drains unyielded part bodies
640
+ * (FR-010), removes 'data'/'error'/'end' listeners on source and 'part'/
641
+ * 'finish' on dicer, unpipes + destroys source, and KEEPS dicer's 'error'
642
+ * listener for late-emit observability (FR-011). Truncation detection
643
+ * (FR-022) fires when source 'end' arrives without dicer 'finish' having
644
+ * fired.
645
+ */
646
+
647
+ /**
648
+ * Parse a `multipart/related` envelope as a typed async-iterator of
649
+ * streaming parts (FR-001).
650
+ *
651
+ * @param input - A Web `Response` (boundary auto-extracted from
652
+ * `Content-Type`) or a Node `Readable` (caller supplies `boundary` via
653
+ * options).
654
+ * @param opts - {@link ParseMultipartOptions}. `idleTimeoutMs` and
655
+ * `totalTimeoutMs` are REQUIRED on this entry point too (FR-006 / JC-3) and
656
+ * are validated synchronously via `validatePositiveTimeout` — both must be
657
+ * positive finite integers in `[1, 2_147_483_647]` (NFR-DR-S-009).
658
+ * @returns An `AsyncGenerator<StreamingMultipartPart, void, void>` that
659
+ * yields parts in dicer's emit order.
660
+ *
661
+ * @throws {TypeError} `multipart: idleTimeoutMs must be a positive finite
662
+ * integer in [1, 2_147_483_647]; …` when `idleTimeoutMs` is missing,
663
+ * non-numeric, non-finite, non-integer, `< 1`, or `> 2^31 - 1`. Same for
664
+ * `totalTimeoutMs`.
665
+ * @throws {Error} `multipart: response body is null` when `input` is a
666
+ * `Response` whose `.body` is `null` (FR-004). The first `.next()` rejects.
667
+ * @throws {Error} `multipart: Content-Type header is required to extract
668
+ * boundary` when the Response has no `Content-Type`.
669
+ * @throws {Error} `multipart: boundary parameter missing from Content-Type`
670
+ * when `Content-Type` is present but lacks a `boundary=` parameter.
671
+ * @throws {Error} `multipart: boundary option is required when input is a
672
+ * Readable` when the input is a Node `Readable` and `opts.boundary` is
673
+ * missing or empty.
674
+ * @throws {MultipartIdleTimeoutError} when no source bytes arrive for
675
+ * `idleTimeoutMs` consecutive ms (FR-007). The idle timer resets on every
676
+ * chunk received from the source (FR-DR-A-025).
677
+ * @throws {MultipartTotalTimeoutError} when the total operation wallclock
678
+ * exceeds `totalTimeoutMs` (FR-008).
679
+ * @throws {MultipartAbortError} when `opts.signal` fires (or is already
680
+ * aborted at call time — first `.next()` rejects synchronously per FR-009).
681
+ * `error.reason` is the caller's `signal.reason` verbatim (F-S-006).
682
+ * @throws {MultipartTruncatedError} when the source emits `'end'` before
683
+ * dicer emits `'finish'` (FR-022).
684
+ *
685
+ * @example
686
+ * for await (const part of parseMultipartRelated(res, {
687
+ * idleTimeoutMs: 5000,
688
+ * totalTimeoutMs: 60_000,
689
+ * })) {
690
+ * console.log(part.contentType, part.contentId);
691
+ * }
692
+ */
693
+ declare function parseMultipartRelated(input: Response, opts: ParseMultipartOptions): AsyncGenerator<StreamingMultipartPart, void, void>;
694
+ declare function parseMultipartRelated(input: Readable, opts: ParseMultipartOptions & {
695
+ boundary: string;
696
+ }): AsyncGenerator<StreamingMultipartPart, void, void>;
697
+
698
+ /**
699
+ * Stream-collection helpers (FR-015 + NFR-DR-S-002).
700
+ *
701
+ * Both helpers drain a Node `Readable` to a single value. They are
702
+ * convenience for small parts (text / binary metadata, manifests) — for
703
+ * larger payloads callers SHOULD pipe directly to a sink instead of
704
+ * buffering.
705
+ *
706
+ * The optional `options.maxBytes` cap (NFR-DR-S-002): when set and the
707
+ * accumulated bytes exceed the cap, the source `Readable` is destroyed and
708
+ * the promise rejects with a clear `Error`. Existing callers that pass only
709
+ * `(readable)` or `(readable, encoding)` see no behavior change.
710
+ *
711
+ * Per kiln/spec/api.md §3 + §4, `streamToBuffer`'s `options` parameter is the
712
+ * second positional arg; `streamToString`'s `options` parameter is the
713
+ * THIRD positional arg (after the legacy `encoding?`). The cap-overflow
714
+ * error is a generic `Error` (NOT a custom class) because these are
715
+ * utilities — the parsing-domain error classes are reserved for
716
+ * `parseMultipartRelated`.
717
+ */
718
+
719
+ /**
720
+ * Options bag accepted by both {@link streamToString} and
721
+ * {@link streamToBuffer}. Currently exposes only `maxBytes` (NFR-DR-S-002);
722
+ * forward-compat-shaped as an interface so additional cap fields can land
723
+ * without breaking callers.
724
+ */
725
+ interface StreamCollectOptions {
726
+ /**
727
+ * Soft cap on accumulated input bytes. When set and the source produces
728
+ * more than this many bytes total, the source `Readable` is destroyed
729
+ * and the promise rejects with a clear `Error`. Omit (default) for no
730
+ * cap. Must be a positive finite integer when set.
731
+ */
732
+ readonly maxBytes?: number | undefined;
733
+ }
734
+ /**
735
+ * Drain a Node `Readable` to a single string.
736
+ *
737
+ * Calls `Buffer.from(chunk).toString(encoding)` for non-Buffer chunks,
738
+ * defending against object-mode-ish streams that emit strings already.
739
+ *
740
+ * @param readable - Source stream. Must end (rejection on `'error'`).
741
+ * @param encoding - Optional `BufferEncoding` (defaults to `'utf8'`).
742
+ * @param options - Optional {@link StreamCollectOptions}. When
743
+ * `options.maxBytes` is set and accumulated bytes exceed the cap, the
744
+ * source is destroyed and the promise rejects (NFR-DR-S-002).
745
+ * @returns A `Promise<string>` resolving to the full decoded contents.
746
+ * @throws Any `'error'` event from `readable` rejects the promise with that
747
+ * error.
748
+ * @throws {Error} `streamToString: input exceeded maxBytes (<n>)` when
749
+ * `options.maxBytes` is set and exceeded. The source `readable` is
750
+ * destroyed before the promise rejects.
751
+ *
752
+ * @example
753
+ * const text = await streamToString(part.body, 'utf8');
754
+ * console.log(JSON.parse(text));
755
+ *
756
+ * @example
757
+ * // With a 1 MiB cap to defend against attacker-controlled part bodies:
758
+ * const text = await streamToString(part.body, 'utf8', { maxBytes: 1_048_576 });
759
+ */
760
+ declare function streamToString(readable: Readable, encoding?: BufferEncoding, options?: StreamCollectOptions): Promise<string>;
761
+ /**
762
+ * Drain a Node `Readable` to a single `Buffer`.
763
+ *
764
+ * @param readable - Source stream.
765
+ * @param options - Optional {@link StreamCollectOptions}. When
766
+ * `options.maxBytes` is set and accumulated bytes exceed the cap, the
767
+ * source is destroyed and the promise rejects (NFR-DR-S-002).
768
+ * @returns A `Promise<Buffer>`. Zero-byte streams resolve to
769
+ * `Buffer.alloc(0)`.
770
+ * @throws Any `'error'` event from `readable` rejects the promise.
771
+ * @throws {Error} `streamToBuffer: input exceeded maxBytes (<n>)` when
772
+ * `options.maxBytes` is set and exceeded. The source `readable` is
773
+ * destroyed before the promise rejects.
774
+ *
775
+ * @example
776
+ * const buf = await streamToBuffer(part.body);
777
+ * await fs.writeFile(`/tmp/${part.contentId ?? 'part'}.bin`, buf);
778
+ *
779
+ * @example
780
+ * // With a 5 MiB cap to defend against attacker-controlled part bodies:
781
+ * const buf = await streamToBuffer(part.body, { maxBytes: 5_242_880 });
782
+ */
783
+ declare function streamToBuffer(readable: Readable, options?: StreamCollectOptions): Promise<Buffer>;
784
+
785
+ export { type Logger, MultipartAbortError, type MultipartFetchResult, type MultipartHandlerOptions, MultipartHeadersTooLargeError, MultipartIdleTimeoutError, MultipartPartTooLargeError, MultipartTooManyPartsError, MultipartTotalTimeoutError, MultipartTruncatedError, type ParseMultipartOptions, type PartParser, type ProgressSnapshot, type StreamingMultipartPart, extractBoundary, fetchAndHandleMultipart, parseMultipartRelated, streamToBuffer, streamToString };