@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 +21 -0
- package/README.md +261 -0
- package/dist/index.cjs +930 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +785 -0
- package/dist/index.d.ts +785 -0
- package/dist/index.js +913 -0
- package/dist/index.js.map +1 -0
- package/package.json +94 -0
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.
|