@andyrmitchell/multipart-mixed 0.1.1

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) 2024 Andy Mitchell
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,88 @@
1
+ # @andyrmitchell/multipart-mixed
2
+
3
+ Streaming parser for `multipart/mixed` HTTP batch responses, such as those returned by Google's batch APIs (Gmail, Calendar, Drive…).
4
+
5
+ - **Streams**: each part reaches your callback as soon as its bytes arrive. A 1,000-part batch never has to sit in memory at once.
6
+ - **Isolates failures per part**: a malformed part is reported as `{parsed: false}`, and a failed sub-request carries its own status. Neither affects the rest of the batch.
7
+ - **Refuses to lose data silently**: a truncated response, or one whose boundary never appears, rejects instead of resolving with fewer parts.
8
+ - **RFC 2046 framing**: delimiters must start a line, so boundary text inside a body is safe. The preamble and epilogue are ignored, and CRLF and bare LF are both accepted.
9
+ - **Zero dependencies**: web-platform APIs only (`Response`, `ReadableStream`, `TextDecoder`). Runs in browsers, service workers and Node 18+.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm i @andyrmitchell/multipart-mixed
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```ts
20
+ import { streamMultipartParts } from '@andyrmitchell/multipart-mixed';
21
+
22
+ const response = await fetch('https://www.googleapis.com/batch/gmail/v1', { method: 'POST', headers, body });
23
+
24
+ await streamMultipartParts(response, async part => {
25
+ if (!part.parsed) {
26
+ // The part's text was malformed. The rest of the batch still arrives.
27
+ console.warn(part.runtimeError?.message);
28
+ return;
29
+ }
30
+ if (part.statusCode !== 200) {
31
+ // This one sub-request failed, e.g. a 404 or 429. Its reply headers are available.
32
+ console.warn(part.contentId, part.statusCode, part.headers['retry-after']);
33
+ return;
34
+ }
35
+ await save(part.bodyData); // Record<string, unknown>: validate before reading nested fields
36
+ });
37
+ ```
38
+
39
+ Use `getMultipartParts(response)` if you want every part as an array. It buffers the whole batch.
40
+
41
+ ### What each part tells you
42
+
43
+ For a readable part (`parsed: true`):
44
+
45
+ | Field | Meaning |
46
+ |---|---|
47
+ | `contentId` | The part's `Content-ID`, with any `<…>` brackets removed. Use it to match replies to sub-requests. |
48
+ | `statusCode` | The embedded reply's HTTP status. |
49
+ | `headers` | The embedded reply's headers, keyed in lower case. |
50
+ | `contentType` | The embedded reply's `Content-Type`, parameters included. |
51
+ | `bodyData` | The body parsed as a JSON object. `undefined` if the body is not a JSON object. |
52
+ | `bodyRaw` | The body text, always present when there is a body. |
53
+
54
+ An unreadable part (`parsed: false`) carries only `runtimeError`, a `ParseError` that holds the offending raw text.
55
+
56
+ ### Error contract
57
+
58
+ The call **rejects** only when the stream as a whole cannot be trusted:
59
+ - the response has no body, or no boundary in its `Content-Type`
60
+ - the boundary never appears in the body
61
+ - the stream ends before the closing `--boundary--`, i.e. it was truncated. Parts completed before that point have already been delivered.
62
+ - your callback throws or rejects. The stream is cancelled and the call rejects with your error.
63
+
64
+ Everything else is reported **per part** and never rejects.
65
+
66
+ ## Testing helpers
67
+
68
+ `@andyrmitchell/multipart-mixed/testing` builds deterministic batch responses for your own tests, with no timers and no randomness:
69
+
70
+ ```ts
71
+ import { buildMultipartBody, createMultipartResponse, jsonPart, responsePart, googleErrorBody } from '@andyrmitchell/multipart-mixed/testing';
72
+
73
+ const body = buildMultipartBody([
74
+ jsonPart('a', '{"id":"a"}'),
75
+ responsePart('response-b', '429 Too Many Requests', JSON.stringify(googleErrorBody(429, 'rateLimitExceeded', 'Slow down'))),
76
+ ]);
77
+ const response = createMultipartResponse(body, { chunkPlan: 'byte-at-a-time' });
78
+ ```
79
+
80
+ `createMultipartResponse` can also simulate truncation (`includeClosingDelimiter: false` on the body), a missing `Content-Type`, zero-length chunks, a failing `cancel()`, and exact chunk split points.
81
+
82
+ ## Scope
83
+
84
+ Each part is expected to wrap an embedded HTTP reply (`Content-Type: application/http`), which is the shape of Google-style batch responses. Parts with no headers of their own, where the text starts directly at the status line, are also accepted.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,7 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+
5
+ export {
6
+ __publicField
7
+ };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Why one part of a batch response could not be read.
3
+ *
4
+ * Found on `runtimeError` of a `{parsed: false}` part. It describes malformed *framing or text*
5
+ * — not a failed sub-request, which parses fine and reports its status in `statusCode`.
6
+ *
7
+ * @example
8
+ * if (!part.parsed && part.runtimeError instanceof ParseError) {
9
+ * log(part.runtimeError.message, part.runtimeError.rawItemString.slice(0, 200));
10
+ * }
11
+ */
12
+ declare class ParseError extends Error {
13
+ /** The raw text of the part that could not be read, exactly as received, for diagnosis. */
14
+ rawItemString: string;
15
+ constructor(message: string,
16
+ /** The raw text of the part that could not be read, exactly as received, for diagnosis. */
17
+ rawItemString: string);
18
+ }
19
+
20
+ /**
21
+ * One part of a multipart/mixed batch response: the reply to a single sub-request.
22
+ *
23
+ * In a batch response (e.g. Google's), each part wraps a complete embedded HTTP reply — status
24
+ * line, headers and body. `parsed` says whether that reply could be read at all:
25
+ * - `parsed: true` — the reply's fields are available. It may still be a *failed* sub-request
26
+ * (check `statusCode`); a parsed part is only a readable part, not a successful one.
27
+ * - `parsed: false` — the part's text was malformed; `runtimeError` carries the reason and the
28
+ * raw text. The rest of the batch is unaffected.
29
+ *
30
+ * @example
31
+ * await streamMultipartParts(response, part => {
32
+ * if (!part.parsed) return reportUnreadable(part.runtimeError);
33
+ * if (part.statusCode !== 200) return reportFailed(part.contentId, part.statusCode);
34
+ * save(part.bodyData);
35
+ * });
36
+ */
37
+ type ParsedPart = {
38
+ parsed: true;
39
+ /** The Content-ID from the multipart item's headers. */
40
+ contentId?: string;
41
+ /** The HTTP status code from the embedded HTTP response (e.g., 200, 304, 404). */
42
+ statusCode?: number;
43
+ /**
44
+ * The Content-Type of the embedded HTTP response's body, exactly as sent — including any
45
+ * parameters (e.g. "application/json; charset=UTF-8").
46
+ * Undefined if the embedded response has no Content-Type header.
47
+ */
48
+ contentType?: string;
49
+ /**
50
+ * The headers of the embedded HTTP response, keyed in lower case.
51
+ *
52
+ * These belong to the reply inside the part, not to the part itself, so `Content-ID` — which
53
+ * labels the part — is never among them. A reply that sent no headers has an empty record.
54
+ *
55
+ * @example
56
+ * // How long a rate-limited part asked the client to wait
57
+ * const retryAfterMs = parseRetryAfterMs(part.headers['retry-after']);
58
+ */
59
+ headers: Record<string, string>;
60
+ /**
61
+ * The parsed JSON data from the embedded HTTP response's body.
62
+ *
63
+ * It may represent the error details on some status codes.
64
+ *
65
+ * Only set when the body parses as a JSON **object**. Undefined if there's no body,
66
+ * the body is not JSON, or the body is a JSON array/primitive/null — in those cases
67
+ * the content is still available via `bodyRaw`.
68
+ *
69
+ * Every field is `unknown` because the body is external data: narrow it with a type guard,
70
+ * or validate it with a schema, before reading anything nested.
71
+ *
72
+ * @example
73
+ * // Reading a Google error code without trusting its shape
74
+ * const error = part.bodyData?.error;
75
+ * const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
76
+ */
77
+ bodyData?: Record<string, unknown>;
78
+ /**
79
+ * The body (from which `data` is inferred with JSON.parse).
80
+ *
81
+ * Useful if it's not application/json, or to explain why the json parse might have failed.
82
+ */
83
+ bodyRaw?: string;
84
+ } | {
85
+ parsed: false;
86
+ /**
87
+ * A parsing problem (not a returned data problem - that will be in the status or the data)
88
+ */
89
+ runtimeError?: ParseError;
90
+ };
91
+ /**
92
+ * Receives each part of a batch response as soon as it has been parsed.
93
+ *
94
+ * Parts arrive in wire order, one at a time: when the callback returns a promise, the next part
95
+ * is not delivered until it settles. Throwing (or rejecting) stops the stream and rejects the
96
+ * surrounding `streamMultipartParts`/`getMultipartParts` call with that error.
97
+ *
98
+ * @param part - The parsed part; see {@link ParsedPart} for how to tell success from failure.
99
+ *
100
+ * @remarks
101
+ * Written as a union of a sync and an async signature, rather than one function returning
102
+ * `void | Promise<void>`, so that a concise arrow whose expression happens to produce a value
103
+ * (e.g. `part => seen.push(part)`) is still accepted.
104
+ */
105
+ type OnItemCallback = ((part: ParsedPart) => void) | ((part: ParsedPart) => Promise<void>);
106
+
107
+ /**
108
+ * Parses a multipart/mixed HTTP response and returns **all** parts as an array.
109
+ *
110
+ * This will buffer every part in memory before resolving.
111
+ *
112
+ *
113
+ * For very large responses where memory is a concern, consider using `streamMultipartParts`
114
+ * directly with a callback and handling parts one by one.
115
+ *
116
+ * @param response - A Fetch API Response whose body is a multipart/mixed stream.
117
+ * @param onItemCallback - Optional. If provided, called for each part as soon as it’s parsed.
118
+ * @returns A Promise that resolves to an array of all parsed parts.
119
+ * @throws {Error} If the response body is not available, if the boundary cannot be determined from
120
+ * the Content-Type header, if the boundary never appears in the body, or if the
121
+ * stream ends before the closing `--boundary--` delimiter (a truncated response).
122
+ * @example
123
+ * ```ts
124
+ * const callbackParts:ParsedPart[] = [];
125
+ * const parts = await getMultipartParts(response, part => {
126
+ * callbackParts.push(part);
127
+ * console.log("got part:", part);
128
+ * });
129
+ * isEqual(parts, callbackParts); // true (using Lodash's isEqual)
130
+ * ```
131
+ */
132
+ declare function getMultipartParts(response: Response, onItemCallback?: OnItemCallback): Promise<ParsedPart[]>;
133
+ /**
134
+ * Parses a multipart/mixed HTTP response and **streams** each part straight to your callback.
135
+ *
136
+ * This never builds a full in-memory array — once a part is parsed it’s emitted, and its bytes
137
+ * are released from the buffer. Parts are emitted in wire order, and each callback is awaited
138
+ * before the next part is emitted.
139
+ *
140
+ * Framing follows RFC 2046: delimiter lines must start at the beginning of a line (so part
141
+ * content that merely mentions the boundary text is safe), the preamble before the first
142
+ * delimiter and the epilogue after the closing `--boundary--` delimiter are ignored, and both
143
+ * CRLF and bare-LF framing are accepted.
144
+ *
145
+ * @param response - A Fetch API Response whose body is a multipart/mixed stream.
146
+ * @param onItemCallback - Called for each parsed part. If it throws, the stream is cancelled and
147
+ * the returned promise rejects with the callback’s error.
148
+ * @returns A Promise that resolves when the closing delimiter has been reached and every
149
+ * callback has completed.
150
+ * @throws {Error} If the response has no body, if the multipart boundary cannot be determined
151
+ * from the Content-Type header, if the boundary never appears in the body, or if
152
+ * the stream ends before the closing delimiter (a truncated response). Parts that
153
+ * completed before a truncation point will already have been emitted.
154
+ * @remarks
155
+ * Malformed *individual* parts do not throw — they are emitted as `{parsed: false}` values so one
156
+ * bad part cannot destroy the rest of the batch. Throwing is reserved for whole-stream integrity
157
+ * failures where continuing would silently lose data.
158
+ * @example
159
+ * ```ts
160
+ * await streamMultipartParts(response, part => {
161
+ * saveToDatabase(part);
162
+ * });
163
+ * // returns void
164
+ * ```
165
+ */
166
+ declare function streamMultipartParts(response: Response, onItemCallback: OnItemCallback): Promise<void>;
167
+
168
+ export { type OnItemCallback, ParseError, type ParsedPart, getMultipartParts, streamMultipartParts };
package/dist/index.js ADDED
@@ -0,0 +1,216 @@
1
+ import {
2
+ __publicField
3
+ } from "./chunk-PKBMQBKP.js";
4
+
5
+ // src/part/parseMultipartMixedItem.ts
6
+ var HTTP_STATUS_LINE_REGEX = /^HTTP\/\d+(?:\.\d+)?\s+(\d{3})\b/i;
7
+ function parseMultipartMixedItem(itemString) {
8
+ try {
9
+ const normalizedItemString = itemString.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
10
+ const majorBlockSeparator = "\n\n";
11
+ const blocks = normalizedItemString.split(majorBlockSeparator);
12
+ let outerHeaderBlock;
13
+ if (HTTP_STATUS_LINE_REGEX.test(normalizedItemString)) {
14
+ outerHeaderBlock = "";
15
+ } else {
16
+ if (blocks.length < 2) {
17
+ return { parsed: false, runtimeError: new ParseError("Malformed item: Missing separator for embedded HTTP response. Expected outer headers and an embedded response.", itemString) };
18
+ }
19
+ outerHeaderBlock = blocks.shift();
20
+ }
21
+ const firstContentBlockIndex = blocks.findIndex((block) => block.trim());
22
+ if (firstContentBlockIndex > 0 && HTTP_STATUS_LINE_REGEX.test(blocks[firstContentBlockIndex])) {
23
+ blocks.splice(0, firstContentBlockIndex);
24
+ }
25
+ const embeddedStatusAndHeaderBlock = blocks.shift();
26
+ if (!embeddedStatusAndHeaderBlock) {
27
+ return { parsed: false, runtimeError: new ParseError("Malformed embedded response: Missing header block.", itemString) };
28
+ }
29
+ const bodyRaw = blocks.join(majorBlockSeparator).trim();
30
+ const outer = parseHeaderBlock(outerHeaderBlock);
31
+ let contentId = outer.headers["content-id"];
32
+ if (contentId && contentId.startsWith("<") && contentId.endsWith(">")) {
33
+ contentId = contentId.slice(1, -1);
34
+ }
35
+ const embedded = parseHeaderBlock(embeddedStatusAndHeaderBlock);
36
+ let data = void 0;
37
+ if (bodyRaw) {
38
+ try {
39
+ const parsedBody = JSON.parse(bodyRaw);
40
+ if (isPlainJsonObject(parsedBody)) data = parsedBody;
41
+ } catch {
42
+ }
43
+ }
44
+ return {
45
+ parsed: true,
46
+ contentId,
47
+ statusCode: embedded.status,
48
+ contentType: embedded.headers["content-type"],
49
+ headers: embedded.headers,
50
+ bodyData: data,
51
+ bodyRaw
52
+ };
53
+ } catch (e) {
54
+ const message = e instanceof Error ? e.message : String(e);
55
+ return {
56
+ parsed: false,
57
+ runtimeError: new ParseError(message, itemString)
58
+ };
59
+ }
60
+ }
61
+ var ParseError = class _ParseError extends Error {
62
+ constructor(message, rawItemString) {
63
+ super(message);
64
+ __publicField(this, "rawItemString", rawItemString);
65
+ this.name = "ParseError";
66
+ Object.setPrototypeOf(this, _ParseError.prototype);
67
+ }
68
+ };
69
+ function isPlainJsonObject(value) {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
72
+ function parseHeaderBlock(headerBlock) {
73
+ const result = { headers: {} };
74
+ let lastHeaderKey;
75
+ for (const rawLine of headerBlock.split("\n")) {
76
+ if (!rawLine.trim()) continue;
77
+ if (/^[ \t]/.test(rawLine) && lastHeaderKey) {
78
+ const valueSoFar = result.headers[lastHeaderKey];
79
+ result.headers[lastHeaderKey] = valueSoFar ? `${valueSoFar} ${rawLine.trim()}` : rawLine.trim();
80
+ continue;
81
+ }
82
+ const statusMatch = rawLine.match(HTTP_STATUS_LINE_REGEX);
83
+ if (statusMatch?.[1]) {
84
+ result.status = parseInt(statusMatch[1], 10);
85
+ lastHeaderKey = void 0;
86
+ continue;
87
+ }
88
+ const line = rawLine.trim();
89
+ const colonIndex = line.indexOf(":");
90
+ if (colonIndex > 0) {
91
+ const key = line.substring(0, colonIndex).trim().toLowerCase();
92
+ result.headers[key] = line.substring(colonIndex + 1).trim();
93
+ lastHeaderKey = key;
94
+ }
95
+ }
96
+ return result;
97
+ }
98
+
99
+ // src/getMultipartParts.ts
100
+ async function getMultipartParts(response, onItemCallback) {
101
+ const parts = [];
102
+ const onItemCallbackWrapped = async (part) => {
103
+ parts.push(part);
104
+ if (onItemCallback) await onItemCallback(part);
105
+ };
106
+ await streamMultipartParts(response, onItemCallbackWrapped);
107
+ return parts;
108
+ }
109
+ async function streamMultipartParts(response, onItemCallback) {
110
+ if (!response.body) {
111
+ throw new Error("Called getMultipartParts on a Response without a body or on a browser that does not support ReadableStream");
112
+ }
113
+ const contentTypeHeader = response.headers.get("content-type");
114
+ const boundary = extractBoundaryToken(contentTypeHeader);
115
+ if (!boundary) throw new Error("Could not read boundary marker from Content-Type: " + contentTypeHeader);
116
+ const dashBoundary = `--${boundary}`;
117
+ const reader = response.body.getReader();
118
+ const decoder = new TextDecoder();
119
+ let buffer = "";
120
+ let scanFrom = 0;
121
+ let sawOpeningDelimiter = false;
122
+ let streamDone = false;
123
+ const classifyDelimiterAt = (index) => {
124
+ let i = index + dashBoundary.length;
125
+ if (i >= buffer.length) return { type: "need-more-data" };
126
+ if (buffer[i] === "-") {
127
+ if (i + 1 >= buffer.length) return { type: "need-more-data" };
128
+ return buffer[i + 1] === "-" ? { type: "closing" } : { type: "not-a-delimiter" };
129
+ }
130
+ while (i < buffer.length && (buffer[i] === " " || buffer[i] === " ")) i++;
131
+ if (i >= buffer.length) return { type: "need-more-data" };
132
+ if (buffer[i] === "\r") {
133
+ i++;
134
+ if (i >= buffer.length) return { type: "need-more-data" };
135
+ }
136
+ if (buffer[i] === "\n") return { type: "middle", contentStart: i + 1 };
137
+ return { type: "not-a-delimiter" };
138
+ };
139
+ const scanForDelimiter = () => {
140
+ let idx = buffer.indexOf(dashBoundary, scanFrom);
141
+ while (idx !== -1) {
142
+ const lineAnchored = idx === 0 || buffer[idx - 1] === "\n";
143
+ if (lineAnchored) {
144
+ const classification = classifyDelimiterAt(idx);
145
+ if (classification.type === "need-more-data") {
146
+ scanFrom = idx;
147
+ return { type: "need-more-data" };
148
+ }
149
+ if (classification.type === "closing") return { type: "closing", index: idx };
150
+ if (classification.type === "middle") return { type: "middle", index: idx, contentStart: classification.contentStart };
151
+ }
152
+ idx = buffer.indexOf(dashBoundary, idx + 1);
153
+ }
154
+ scanFrom = Math.max(0, buffer.length - dashBoundary.length - 1);
155
+ return { type: "need-more-data" };
156
+ };
157
+ const emitPart = async (rawPart) => {
158
+ if (!rawPart.trim()) return;
159
+ const parsedPart = parseMultipartMixedItem(rawPart);
160
+ await onItemCallback(parsedPart);
161
+ };
162
+ try {
163
+ readLoop: while (true) {
164
+ const { value, done } = await reader.read();
165
+ if (value) buffer += decoder.decode(value, { stream: true });
166
+ if (done) {
167
+ buffer += decoder.decode();
168
+ streamDone = true;
169
+ }
170
+ while (true) {
171
+ const scan = scanForDelimiter();
172
+ if (scan.type === "need-more-data") {
173
+ if (!streamDone) continue readLoop;
174
+ if (!sawOpeningDelimiter) {
175
+ throw new Error(`Multipart boundary "${dashBoundary}" was never found in the response body. Content-Type was: ${contentTypeHeader}`);
176
+ }
177
+ throw new Error("Multipart response ended before the closing boundary delimiter. The response appears to be truncated.");
178
+ }
179
+ if (scan.type === "closing") {
180
+ if (sawOpeningDelimiter) await emitPart(buffer.substring(0, scan.index));
181
+ buffer = "";
182
+ if (!streamDone) {
183
+ await reader.cancel().catch(() => {
184
+ });
185
+ }
186
+ break readLoop;
187
+ }
188
+ if (sawOpeningDelimiter) await emitPart(buffer.substring(0, scan.index));
189
+ sawOpeningDelimiter = true;
190
+ buffer = buffer.substring(scan.contentStart);
191
+ scanFrom = 0;
192
+ }
193
+ }
194
+ } catch (e) {
195
+ await reader.cancel().catch(() => {
196
+ });
197
+ throw e;
198
+ } finally {
199
+ try {
200
+ reader.releaseLock();
201
+ } catch {
202
+ }
203
+ }
204
+ }
205
+ function extractBoundaryToken(contentTypeHeader) {
206
+ if (!contentTypeHeader) return null;
207
+ const match = contentTypeHeader.match(/boundary\s*=\s*(?:"([^"]*)"|([^;]+))/i);
208
+ if (!match) return null;
209
+ const token = (match[1] ?? match[2] ?? "").trim();
210
+ return token || null;
211
+ }
212
+ export {
213
+ ParseError,
214
+ getMultipartParts,
215
+ streamMultipartParts
216
+ };
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Deterministic builders for multipart/mixed `Response` objects in tests.
3
+ *
4
+ * `buildMultipartBody` assembles RFC 2046 framing (delimiter lines, optional
5
+ * preamble/epilogue/closing delimiter) around part strings, and
6
+ * `createMultipartResponse` wraps a body in a real `Response` whose
7
+ * `ReadableStream` delivers bytes according to an explicit chunk plan —
8
+ * no timers, no randomness, so chunk-boundary behavior is reproducible.
9
+ */
10
+ /** The line ending used for multipart framing: CRLF as the RFC requires, or bare LF as some servers send. */
11
+ type MultipartLineEnding = '\r\n' | '\n';
12
+ type BuildMultipartBodyOptions = {
13
+ /** Boundary token used in delimiter lines. Defaults to `batch_foobarbaz`. */
14
+ boundary?: string;
15
+ /**
16
+ * Line ending used for all framing; part strings are normalized to it too.
17
+ * Defaults to CRLF, matching the real wire format.
18
+ */
19
+ lineEnding?: MultipartLineEnding;
20
+ /** Text placed before the first delimiter line (RFC 2046 preamble). */
21
+ preamble?: string;
22
+ /** Text placed after the closing delimiter (RFC 2046 epilogue). */
23
+ epilogue?: string;
24
+ /**
25
+ * Emit the final `--boundary--` closing delimiter.
26
+ * Defaults to true — real servers always send it; set false to simulate truncation.
27
+ */
28
+ includeClosingDelimiter?: boolean;
29
+ };
30
+ /**
31
+ * Assemble a multipart/mixed body string from part contents.
32
+ *
33
+ * Each entry in `parts` is the full content of one part (its headers, blank
34
+ * line, and embedded payload), written with any line endings — they are
35
+ * normalized to `lineEnding`. The framing around them is exactly what a server
36
+ * sends, so the result can be fed to `createMultipartResponse` as-is.
37
+ *
38
+ * @param parts - The content of each part, in wire order. `responsePart` and `jsonPart` build these.
39
+ * @param options - Boundary, line ending, preamble/epilogue, and whether to close the body.
40
+ * @returns The complete body text, from the first delimiter line (or preamble) to the closing
41
+ * delimiter (or epilogue).
42
+ * @example
43
+ * const body = buildMultipartBody([jsonPart('a', '{"id":"a"}')]);
44
+ * // '--batch_foobarbaz\r\nContent-Type: application/http\r\n...\r\n--batch_foobarbaz--'
45
+ *
46
+ * // A response cut off before its closing delimiter
47
+ * const truncated = buildMultipartBody([jsonPart('a', '{"id":"a"}')], { includeClosingDelimiter: false });
48
+ */
49
+ declare function buildMultipartBody(parts: string[], options?: BuildMultipartBodyOptions): string;
50
+ /**
51
+ * How a test response's body bytes are split into stream chunks.
52
+ *
53
+ * - `'single'`: the whole body in one chunk.
54
+ * - `'byte-at-a-time'`: one chunk per byte, the harshest split possible.
55
+ * - `{ chunkSize }`: fixed-size chunks; the last may be shorter.
56
+ * - `{ splitAtBytes }`: split at exact byte offsets (out-of-range or repeated offsets are ignored),
57
+ * to land a chunk edge precisely inside a delimiter or a multi-byte character.
58
+ */
59
+ type ChunkPlan = 'single' | 'byte-at-a-time' | {
60
+ chunkSize: number;
61
+ } | {
62
+ splitAtBytes: number[];
63
+ };
64
+ type CreateMultipartResponseOptions = {
65
+ /** Boundary advertised in the default Content-Type header. Defaults to `batch_foobarbaz`. */
66
+ boundary?: string;
67
+ /**
68
+ * Full Content-Type header value. `null` omits the header entirely.
69
+ * Defaults to `multipart/mixed; boundary=${boundary}`.
70
+ */
71
+ contentType?: string | null;
72
+ /** How the body bytes are split into stream chunks. Defaults to a single chunk. */
73
+ chunkPlan?: ChunkPlan;
74
+ /** Enqueue a zero-length chunk before every data chunk (browsers may deliver these). */
75
+ interleaveEmptyChunks?: boolean;
76
+ /** Make the underlying stream's cancel() throw — for testing cancellation error handling. */
77
+ cancelRejects?: boolean;
78
+ /**
79
+ * Called each time the stream hands over a chunk, with the body bytes handed over so far and
80
+ * the body's total size. Lets a test observe how much of the response existed at the moment
81
+ * something happened — e.g. that a part reached a consumer before the body was fully read.
82
+ */
83
+ onChunkDelivered?: (bytesDeliveredSoFar: number, totalBytes: number) => void;
84
+ };
85
+ /**
86
+ * Wrap a multipart body in a real `Response` whose stream delivers bytes
87
+ * according to `chunkPlan`, deterministically (pull-based, no timers).
88
+ *
89
+ * A parser under test reads it exactly as it would read a `fetch` result, so
90
+ * chunk-boundary bugs reproduce on every run instead of depending on the network.
91
+ *
92
+ * @param body - The response body, usually from `buildMultipartBody`.
93
+ * @param options - Content-Type (or its absence), chunk plan, and hooks for
94
+ * observing delivery or forcing `cancel()` to fail.
95
+ * @returns A `Response` with a streaming body and a `multipart/mixed` Content-Type
96
+ * (unless `contentType` overrides it).
97
+ * @example
98
+ * const response = createMultipartResponse(body, { chunkPlan: 'byte-at-a-time' });
99
+ * const parts = await getMultipartParts(response);
100
+ */
101
+ declare function createMultipartResponse(body: string, options?: CreateMultipartResponseOptions): Response;
102
+
103
+ /**
104
+ * Builders for the parts of a Google batch response, in the shape Gmail sends them.
105
+ *
106
+ * Every part of a batch response wraps one embedded HTTP reply: the part's own headers
107
+ * (`Content-Type: application/http`, usually a `Content-ID`), a blank line, then the reply's
108
+ * status line, headers, a blank line and its body. Building that wire shape in one place means
109
+ * every test that needs a success, an error or a body-less reply writes it the same way.
110
+ *
111
+ * Parts are returned with LF line endings; `buildMultipartBody` normalises them to whichever
112
+ * line ending the framing uses.
113
+ */
114
+ /**
115
+ * Build one batch response part wrapping an embedded HTTP reply.
116
+ *
117
+ * @param contentId - The part's `Content-ID`, written exactly as given (Gmail sends it without
118
+ * angle brackets). `null` omits the header, as a misbehaving server might.
119
+ * @param statusLine - The embedded reply's status after `HTTP/1.1 `, e.g. `'429 Too Many Requests'`.
120
+ * @param body - The embedded reply's body text. `null` sends a reply with neither a body nor a
121
+ * `Content-Type`, as a `204 No Content` does.
122
+ * @param contentType - The embedded reply's `Content-Type`. Defaults to Gmail's JSON type.
123
+ * @returns The part's text, ready to pass to `buildMultipartBody`.
124
+ * @example
125
+ * buildMultipartBody([
126
+ * responsePart('response-a', '200 OK', JSON.stringify({ id: 'a' })),
127
+ * responsePart('response-b', '204 No Content', null)
128
+ * ]);
129
+ */
130
+ declare function responsePart(contentId: string | null, statusLine: string, body: string | null, contentType?: string): string;
131
+ /**
132
+ * Build a JSON batch response part whose `Content-ID` is `response-<id>`.
133
+ *
134
+ * A shorthand for the commonest part in splitting tests: a JSON reply identified by a short id.
135
+ *
136
+ * @param id - Suffix for the part's `Content-ID`; `'a'` gives `response-a`.
137
+ * @param json - The embedded reply's body, as JSON text.
138
+ * @param statusLine - The embedded reply's status after `HTTP/1.1 `. Defaults to `'200 OK'`.
139
+ * @returns The part's text, ready to pass to `buildMultipartBody`.
140
+ * @example
141
+ * jsonPart('a', '{"id":"a"}'); // a 200 whose Content-ID is response-a
142
+ * jsonPart('b', '{"error":{"code":404}}', '404 Not Found');
143
+ */
144
+ declare function jsonPart(id: string, json: string, statusLine?: string): string;
145
+ /**
146
+ * Build the error body a Google API returns alongside a failing status.
147
+ *
148
+ * Batch consumers read the `reason` to tell, for example, a rate limit (`rateLimitExceeded`)
149
+ * from a missing resource, so error tests need this envelope rather than an arbitrary object.
150
+ *
151
+ * @param code - The HTTP status the error accompanies, e.g. `429`.
152
+ * @param reason - Google's machine-readable reason, e.g. `'rateLimitExceeded'`.
153
+ * @param message - The human-readable message.
154
+ * @returns `{ error: { errors: [{ domain: 'global', reason, message }], code, message } }`.
155
+ * @example
156
+ * responsePart('response-a', '429 Too Many Requests', JSON.stringify(googleErrorBody(429, 'rateLimitExceeded', 'Slow down')));
157
+ */
158
+ declare function googleErrorBody(code: number, reason: string, message: string): {
159
+ error: {
160
+ errors: {
161
+ domain: string;
162
+ reason: string;
163
+ message: string;
164
+ }[];
165
+ code: number;
166
+ message: string;
167
+ };
168
+ };
169
+
170
+ export { type BuildMultipartBodyOptions, type ChunkPlan, type CreateMultipartResponseOptions, type MultipartLineEnding, buildMultipartBody, createMultipartResponse, googleErrorBody, jsonPart, responsePart };
@@ -0,0 +1,104 @@
1
+ import "./chunk-PKBMQBKP.js";
2
+
3
+ // src/testing/createMultipartResponse.ts
4
+ function buildMultipartBody(parts, options = {}) {
5
+ const {
6
+ boundary = "batch_foobarbaz",
7
+ lineEnding = "\r\n",
8
+ preamble,
9
+ epilogue,
10
+ includeClosingDelimiter = true
11
+ } = options;
12
+ const normalize = (s) => s.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").join(lineEnding);
13
+ const dashBoundary = `--${boundary}`;
14
+ let body = "";
15
+ if (preamble !== void 0) body += normalize(preamble) + lineEnding;
16
+ for (const part of parts) {
17
+ body += dashBoundary + lineEnding + normalize(part) + lineEnding;
18
+ }
19
+ if (includeClosingDelimiter) {
20
+ body += dashBoundary + "--";
21
+ if (epilogue !== void 0) body += lineEnding + normalize(epilogue);
22
+ }
23
+ return body;
24
+ }
25
+ function createMultipartResponse(body, options = {}) {
26
+ const {
27
+ boundary = "batch_foobarbaz",
28
+ contentType = `multipart/mixed; boundary=${boundary}`,
29
+ chunkPlan = "single",
30
+ interleaveEmptyChunks = false,
31
+ cancelRejects = false,
32
+ onChunkDelivered
33
+ } = options;
34
+ const bytes = new TextEncoder().encode(body);
35
+ const dataChunks = splitIntoChunks(bytes, chunkPlan);
36
+ const chunks = interleaveEmptyChunks ? dataChunks.flatMap((chunk) => [new Uint8Array(0), chunk]) : dataChunks;
37
+ let nextChunk = 0;
38
+ let bytesDelivered = 0;
39
+ const stream = new ReadableStream({
40
+ pull(controller) {
41
+ if (nextChunk < chunks.length) {
42
+ const chunk = chunks[nextChunk];
43
+ controller.enqueue(chunk);
44
+ nextChunk++;
45
+ bytesDelivered += chunk.byteLength;
46
+ onChunkDelivered?.(bytesDelivered, bytes.length);
47
+ } else {
48
+ controller.close();
49
+ }
50
+ },
51
+ cancel() {
52
+ if (cancelRejects) throw new Error("Underlying source cancel failure");
53
+ }
54
+ });
55
+ const headers = new Headers();
56
+ if (contentType !== null) headers.set("Content-Type", contentType);
57
+ return new Response(stream, { headers });
58
+ }
59
+ function splitIntoChunks(bytes, plan) {
60
+ if (plan === "single") return [bytes];
61
+ if (plan === "byte-at-a-time") return splitIntoChunks(bytes, { chunkSize: 1 });
62
+ const out = [];
63
+ if ("chunkSize" in plan) {
64
+ for (let i = 0; i < bytes.length; i += plan.chunkSize) {
65
+ out.push(bytes.subarray(i, i + plan.chunkSize));
66
+ }
67
+ return out;
68
+ }
69
+ const offsets = [...plan.splitAtBytes].sort((a, b) => a - b);
70
+ let previous = 0;
71
+ for (const offset of offsets) {
72
+ if (offset <= previous || offset >= bytes.length) continue;
73
+ out.push(bytes.subarray(previous, offset));
74
+ previous = offset;
75
+ }
76
+ out.push(bytes.subarray(previous));
77
+ return out;
78
+ }
79
+
80
+ // src/testing/batchResponseParts.ts
81
+ function responsePart(contentId, statusLine, body, contentType = "application/json; charset=UTF-8") {
82
+ return `Content-Type: application/http${contentId !== null ? `
83
+ Content-ID: ${contentId}` : ""}
84
+
85
+ HTTP/1.1 ${statusLine}${body !== null ? `
86
+ Content-Type: ${contentType}
87
+
88
+ ${body}` : ""}`;
89
+ }
90
+ function jsonPart(id, json, statusLine = "200 OK") {
91
+ return responsePart(`response-${id}`, statusLine, json);
92
+ }
93
+ function googleErrorBody(code, reason, message) {
94
+ return {
95
+ error: { errors: [{ domain: "global", reason, message }], code, message }
96
+ };
97
+ }
98
+ export {
99
+ buildMultipartBody,
100
+ createMultipartResponse,
101
+ googleErrorBody,
102
+ jsonPart,
103
+ responsePart
104
+ };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@andyrmitchell/multipart-mixed",
3
+ "version": "0.1.1",
4
+ "description": "Streaming parser for multipart/mixed HTTP batch responses (e.g. Google batch APIs), with per-part error isolation and truncation detection.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./testing": {
13
+ "types": "./dist/testing.d.ts",
14
+ "default": "./dist/testing.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "pkglint": "./build/publint_pipeable.sh",
27
+ "build_release": "npm run build_prepare && np --no-cleanup",
28
+ "build_prepare": "npm run build && npm run pkglint",
29
+ "build": "tsup",
30
+ "test": "vitest run",
31
+ "typecheck": "tsc --noEmit",
32
+ "lint": "eslint .",
33
+ "prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run build"
34
+ },
35
+ "keywords": [
36
+ "multipart",
37
+ "multipart/mixed",
38
+ "batch",
39
+ "gmail",
40
+ "google-api",
41
+ "stream",
42
+ "parser"
43
+ ],
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/andymitchell/multipart-mixed.git"
47
+ },
48
+ "homepage": "https://github.com/andymitchell/multipart-mixed#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/andymitchell/multipart-mixed/issues"
51
+ },
52
+ "author": "Andy Mitchell",
53
+ "license": "MIT",
54
+ "engines": {
55
+ "node": ">=18.0.0"
56
+ },
57
+ "devDependencies": {
58
+ "@eslint/js": "^10.0.1",
59
+ "@types/node": "^25.9.8",
60
+ "eslint": "^10.4.1",
61
+ "tsup": "^8.5.1",
62
+ "typescript": "^6.0.3",
63
+ "typescript-eslint": "^8.60.1",
64
+ "vitest": "^4.1.8"
65
+ }
66
+ }