@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Hobbs <michael.lee.hobbs@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # @ubercode/multipart-stream
2
+
3
+ A focused TypeScript library for consuming `multipart/related` HTTP responses
4
+ as a typed async-iterator of streaming parts, with production-grade
5
+ timeout / abort / cleanup hygiene.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @ubercode/multipart-stream
11
+ # or
12
+ npm install @ubercode/multipart-stream
13
+ # or
14
+ yarn add @ubercode/multipart-stream
15
+ ```
16
+
17
+ Requires Node `>= 20.18.0`. Single runtime dependency: `dicer@0.3.1` (pinned
18
+ exact).
19
+
20
+ ## Quickstart
21
+
22
+ The most common shape — `fetch` a `multipart/related` response and route
23
+ each part through your own per-part `parser` callback — is one call:
24
+
25
+ ```ts
26
+ import {
27
+ fetchAndHandleMultipart,
28
+ streamToString,
29
+ MultipartIdleTimeoutError,
30
+ MultipartTotalTimeoutError,
31
+ MultipartAbortError,
32
+ MultipartTruncatedError,
33
+ } from '@ubercode/multipart-stream';
34
+
35
+ interface MetaPart { kind: 'meta'; payload: unknown }
36
+ interface BlobPart { kind: 'blob'; bytes: number; contentId?: string }
37
+ type Part = MetaPart | BlobPart;
38
+
39
+ const ctrl = new AbortController();
40
+
41
+ try {
42
+ const result = await fetchAndHandleMultipart<Part>(
43
+ 'https://api.example.com/blob',
44
+ {
45
+ idleTimeoutMs: 30_000,
46
+ totalTimeoutMs: 5 * 60_000,
47
+ signal: ctrl.signal,
48
+ onProgress: ({ bytes, elapsedMs, rateBps }) => {
49
+ console.log(`${bytes}B in ${elapsedMs}ms (${Math.round(rateBps)}B/s)`);
50
+ },
51
+ parser: async (part) => {
52
+ if (part.contentType.startsWith('application/json')) {
53
+ // Small JSON metadata part — drain to string and parse.
54
+ const text = await streamToString(part.body, 'utf8', {
55
+ maxBytes: 1_048_576, // 1 MiB cap (NFR-DR-S-002)
56
+ });
57
+ return { kind: 'meta', payload: JSON.parse(text) };
58
+ }
59
+ // Binary blob — stream-drain and count bytes; never buffer.
60
+ let bytes = 0;
61
+ for await (const chunk of part.body) {
62
+ bytes += (chunk as Buffer).length;
63
+ }
64
+ return { kind: 'blob', bytes, contentId: part.contentId };
65
+ },
66
+ },
67
+ );
68
+ console.log(`Got ${result.parts.length} parts in ${result.elapsedMs}ms`);
69
+ console.log(`HTTP status: ${result.status}, total bytes: ${result.bytes}`);
70
+ } catch (err) {
71
+ if (err instanceof MultipartIdleTimeoutError) {
72
+ console.error(`Server stalled; idle timeout fired (${err.idleTimeoutMs}ms)`);
73
+ } else if (err instanceof MultipartTotalTimeoutError) {
74
+ console.error(`Took too long overall (${err.totalTimeoutMs}ms)`);
75
+ } else if (err instanceof MultipartAbortError) {
76
+ console.error('Aborted by caller', err.reason);
77
+ } else if (err instanceof MultipartTruncatedError) {
78
+ console.error(`Stream truncated after ${err.bytesReceived}B`);
79
+ } else {
80
+ throw err;
81
+ }
82
+ }
83
+ ```
84
+
85
+ For raw `Readable` inputs (e.g. parsing a `multipart/related` request body
86
+ on the server side) use `parseMultipartRelated` directly:
87
+
88
+ ```ts
89
+ import { parseMultipartRelated } from '@ubercode/multipart-stream';
90
+
91
+ for await (const part of parseMultipartRelated(req as unknown as Readable, {
92
+ idleTimeoutMs: 30_000,
93
+ totalTimeoutMs: 5 * 60_000,
94
+ boundary: 'YOUR-BOUNDARY-FROM-CONTENT-TYPE',
95
+ })) {
96
+ // Either drain part.body or destroy it before requesting the next part.
97
+ for await (const chunk of part.body) {
98
+ // process
99
+ void chunk;
100
+ }
101
+ }
102
+ ```
103
+
104
+ The library never pauses dicer's internal state machine on your behalf —
105
+ your parser must drain or destroy each `part.body` before requesting the
106
+ next part. If you don't, the iterator's `finally` destroys leftover bodies
107
+ for you (FR-010), but that costs latency.
108
+
109
+ ## API
110
+
111
+ Single entry point. Submodule exports are NOT supported (one entry, one
112
+ contract).
113
+
114
+ | Symbol | Kind | Description |
115
+ | ------------------------------------ | ------ | ----------- |
116
+ | `fetchAndHandleMultipart(url, opts)` | fn | `fetch` wrapper with idle/total timeout, AbortSignal, progress, and per-part `parser` callback. Resolves `{ parts, bytes, elapsedMs, status, headers }`. |
117
+ | `parseMultipartRelated(input, opts)` | fn | Async-generator yielding `StreamingMultipartPart` per envelope sub-part. Accepts a `Response` (boundary auto-extracted) or a Node `Readable` + explicit `boundary`. |
118
+ | `streamToString(readable, encoding?, opts?)` | fn | Drain a Node `Readable` to a string. Supports `{ maxBytes }` cap. |
119
+ | `streamToBuffer(readable, opts?)` | fn | Drain a Node `Readable` to a `Buffer`. Supports `{ maxBytes }` cap. |
120
+ | `extractBoundary(contentTypeHeader)` | fn | RFC 2046 boundary extractor (ReDoS-resistant). |
121
+ | `MultipartIdleTimeoutError` | class | Thrown when no source bytes arrive for `idleTimeoutMs`. |
122
+ | `MultipartTotalTimeoutError` | class | Thrown when total wallclock exceeds `totalTimeoutMs`. |
123
+ | `MultipartAbortError` | class | Thrown when the caller's `AbortSignal` fires. `reason` carries the signal's reason verbatim. |
124
+ | `MultipartTruncatedError` | class | Thrown when source ends without the closing boundary. `bytesReceived` is the cumulative source byte count. |
125
+ | `MultipartPartTooLargeError` | class | Thrown when a part body exceeds `maxPartBytes`. Carries `{ maxPartBytes, partIndex, bytesReceived }`. |
126
+ | `MultipartHeadersTooLargeError` | class | Thrown when a part exceeds `maxHeadersPerPart` (default 100) or `maxHeaderBytesPerPart` (default 16 KiB). Carries `{ limit, partIndex, cap, observed }`. |
127
+ | `MultipartTooManyPartsError` | class | Thrown when an envelope exceeds `maxParts` (default 10 000). Carries `{ maxParts, observed }`. |
128
+ | `StreamingMultipartPart` | type | Yielded shape: `{ index, headers, body, contentType, contentId?, contentLength?, rawHeaders, boundary }`. |
129
+ | `PartParser<T>` | type | `(part: StreamingMultipartPart) => Promise<T \| undefined>`. |
130
+ | `MultipartFetchResult<T>` | type | `{ parts, bytes, elapsedMs, status, headers }` (no `response` field — body is consumed by the time the result resolves). |
131
+ | `ParseMultipartOptions` | type | Options for `parseMultipartRelated`. |
132
+ | `MultipartHandlerOptions<T>` | type | Options for `fetchAndHandleMultipart`. |
133
+ | `ProgressSnapshot` | type | `{ bytes, elapsedMs, rateBps }`. |
134
+ | `Logger` | type | `(event: { level: 'warn'; msg: string; meta?: unknown }) => void`. |
135
+
136
+ For full JSDoc on every symbol see the `.d.ts` file or each symbol's
137
+ hover-doc in your editor.
138
+
139
+ ## Error handling
140
+
141
+ The library throws seven typed `Error` subclasses. Branch on `instanceof`
142
+ in single-format consumers:
143
+
144
+ ```ts
145
+ import {
146
+ MultipartIdleTimeoutError,
147
+ MultipartTotalTimeoutError,
148
+ MultipartAbortError,
149
+ MultipartTruncatedError,
150
+ MultipartPartTooLargeError,
151
+ MultipartHeadersTooLargeError,
152
+ MultipartTooManyPartsError,
153
+ } from '@ubercode/multipart-stream';
154
+
155
+ function classify(err: unknown): string {
156
+ if (err instanceof MultipartIdleTimeoutError) return 'idle';
157
+ if (err instanceof MultipartTotalTimeoutError) return 'total';
158
+ if (err instanceof MultipartAbortError) return 'abort';
159
+ if (err instanceof MultipartTruncatedError) return 'truncated';
160
+ if (err instanceof MultipartPartTooLargeError) return 'part-too-large';
161
+ if (err instanceof MultipartHeadersTooLargeError) return 'headers-too-large';
162
+ if (err instanceof MultipartTooManyPartsError) return 'too-many-parts';
163
+ return 'other';
164
+ }
165
+ ```
166
+
167
+ Each error class carries structured fields for telemetry and recovery:
168
+
169
+ | Error class | Structured fields |
170
+ | ------------------------------------ | ----------------- |
171
+ | `MultipartIdleTimeoutError` | `idleTimeoutMs: number` |
172
+ | `MultipartTotalTimeoutError` | `totalTimeoutMs: number` |
173
+ | `MultipartAbortError` | `reason?: unknown` (caller-supplied verbatim per F-S-006 — never synthesized from server bytes) |
174
+ | `MultipartTruncatedError` | `bytesReceived: number` |
175
+ | `MultipartPartTooLargeError` | `maxPartBytes: number`, `partIndex: number`, `bytesReceived: number` |
176
+ | `MultipartHeadersTooLargeError` | `limit: 'count' \| 'bytes'`, `partIndex: number`, `cap: number`, `observed: number` |
177
+ | `MultipartTooManyPartsError` | `maxParts: number`, `observed: number` |
178
+
179
+ ### Cross-format `err.name` fallback (NFR-DR-D-007)
180
+
181
+ If your project mixes ESM and CJS imports of `@ubercode/multipart-stream` —
182
+ for example, a CJS application loads a CJS dependency that itself
183
+ `require`s this library while another transitive dependency `import`s it —
184
+ `err instanceof MultipartIdleTimeoutError` may return `false` across the
185
+ module-format boundary, because each bundle has its own copy of the class
186
+ constructor.
187
+
188
+ Every error class sets `err.name` to its class name verbatim
189
+ (`'MultipartIdleTimeoutError'`, `'MultipartTotalTimeoutError'`, …). Use the
190
+ name as a stable fallback that survives both minification and the
191
+ ESM/CJS boundary:
192
+
193
+ ```ts
194
+ function classifyByName(err: unknown): string {
195
+ if (!(err instanceof Error)) return 'other';
196
+ switch (err.name) {
197
+ case 'MultipartIdleTimeoutError': return 'idle';
198
+ case 'MultipartTotalTimeoutError': return 'total';
199
+ case 'MultipartAbortError': return 'abort';
200
+ case 'MultipartTruncatedError': return 'truncated';
201
+ case 'MultipartPartTooLargeError': return 'part-too-large';
202
+ case 'MultipartHeadersTooLargeError': return 'headers-too-large';
203
+ case 'MultipartTooManyPartsError': return 'too-many-parts';
204
+ default: return 'other';
205
+ }
206
+ }
207
+ ```
208
+
209
+ Single-format consumers (pure ESM or pure CJS) can rely on `instanceof`
210
+ exclusively. The `err.name` fallback is verified end-to-end against the
211
+ published `dist/` bundle by the cross-format consumer test
212
+ (`tests/integration/cross-format.test.ts`).
213
+
214
+ ## Compatibility
215
+
216
+ - **Node:** `>= 20.18.0` (last Node 20 LTS, with the stabilized
217
+ `Readable.fromWeb` backpressure fixes the library relies on).
218
+ - **Module formats:** dual ESM (`dist/index.js`) + CJS (`dist/index.cjs`)
219
+ + per-format types (`dist/index.d.ts` + `dist/index.d.cts`). One entry;
220
+ no submodule exports.
221
+ - **Type-resolution:** Audited under
222
+ [`@arethetypeswrong/cli`](https://github.com/arethetypeswrong/arethetypeswrong.github.io)
223
+ with all four moduleResolution scenarios passing
224
+ (`node10`, `node16-cjs`, `node16-esm`, `bundler`). Per-format
225
+ `types` conditions are wired in `package.json#exports`.
226
+ - **Tree-shaking:** `package.json#sideEffects` is `false`.
227
+ - **No browser support claim.** The library uses `node:stream`. Bundlers
228
+ that emulate Node streams may work but are not on the support matrix.
229
+ - **Concurrency contract (F-A-005):** the async-generator from
230
+ `parseMultipartRelated` does NOT promise serialization of concurrent
231
+ `iter.next()` calls. The library MAY but is not required to detect the
232
+ race. The contract is that the process does not crash via
233
+ `uncaughtException` — first-fire wins; subsequent settles are accepted.
234
+ Single-threaded `for await (...)` consumers (the documented usage)
235
+ never hit this race.
236
+
237
+ ### Resource caps and security defaults
238
+
239
+ The library applies conservative caps on every operation, all opt-out via
240
+ the corresponding option:
241
+
242
+ | Option | Default | Spec ref |
243
+ | ---------------------------- | ----------- | ---------------- |
244
+ | `maxParts` | `10_000` | NFR-DR-S-012 |
245
+ | `maxHeadersPerPart` | `100` | NFR-DR-S-004 |
246
+ | `maxHeaderBytesPerPart` | `16_384` | NFR-DR-S-004 |
247
+ | `maxPartBytes` | unset (no cap unless caller sets it) | NFR-DR-S-001 |
248
+
249
+ Setting `maxPartBytes` is recommended whenever the response is
250
+ attacker-influenced. The error paths (`MultipartPartTooLargeError`,
251
+ `MultipartHeadersTooLargeError`, `MultipartTooManyPartsError`) destroy
252
+ the source stream and run full FR-010 cleanup before surfacing.
253
+
254
+ Per F-S-006, `MultipartAbortError.reason` carries the caller-supplied
255
+ `AbortSignal.reason` verbatim or is `undefined`. The library never
256
+ synthesizes a reason that embeds server-derived bytes, so the field is
257
+ safe to log without sanitization.
258
+
259
+ ## License
260
+
261
+ [MIT](LICENSE) — Copyright (c) 2026 Michael Hobbs.