Make the multipart viewer tolerant of preambles, batch responses and cut-off bodies (#821)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and GitHub committed 2026-11-06 18:19:19 -07:00
1 parent c629d4c66c
commit f63586f9f0
5 files changed
+992 -43

No files matched your search

@@ -0,0 +1,122 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test, vi } from "vite-plus/test";
import {
GMAIL_BATCH_BODY,
GMAIL_BATCH_BOUNDARY,
GMAIL_BATCH_CONTENT_TYPE,
} from "../../lib/multipart.fixtures";
import { MAX_MULTIPART_DEPTH, MultipartViewer } from "./MultipartViewer";
// Stand-ins that show which viewer a part went to, without the editor or the tab machinery
vi.mock("./TextViewer", () => ({
TextViewer: ({ text, language }: { text: string; language?: string }) => (
<pre data-viewer="text" data-language={language}>
{text}
</pre>
),
}));
vi.mock("./HexdumpViewer", () => ({
HexdumpViewer: ({ data }: { data: Uint8Array }) => (
<pre data-viewer="hexdump">{data.length} bytes</pre>
),
}));
vi.mock("../tabs/Tabs", () => ({
Tabs: ({ tabs, children }: { tabs: { label: string }[]; children: ReactNode }) => (
<div>
{tabs.map((t) => (
<button key={t.label} type="button">
{t.label}
</button>
))}
{children}
</div>
),
TabContent: ({ children }: { children: ReactNode }) => <section>{children}</section>,
}));
vi.mock("../HeadersTable", () => ({
HeadersTable: ({ headers }: { headers: { name: string; value: string }[] }) => (
<dl>
{headers.map((h) => (
<dt key={h.name + h.value}>
{h.name}: {h.value}
</dt>
))}
</dl>
),
}));
const bytes = (s: string) => new TextEncoder().encode(s);
describe("MultipartViewer", () => {
test("renders Gmail's batch response as a nested HTTP response", () => {
const markup = renderToStaticMarkup(
<MultipartViewer
data={bytes(GMAIL_BATCH_BODY)}
boundary={GMAIL_BATCH_CONTENT_TYPE.split("boundary=")[1]!}
/>,
);
expect(markup).not.toContain("Failed to parse");
expect(markup).toContain(">response-gtlr_43499</button>");
expect(markup).toContain("200");
expect(markup).toContain("OK");
expect(markup).toContain("Vary: X-Origin");
expect(markup).toContain('data-viewer="text" data-language="json"');
expect(markup).toContain("&quot;messagesTotal&quot;: 23545");
expect(markup).not.toContain(GMAIL_BATCH_BOUNDARY);
});
test("renders a multipart part recursively", () => {
const body = [
"--outer",
'Content-Type: multipart/alternative; boundary="inner"',
"",
"--inner",
"Content-Type: text/plain",
"",
"deep",
"--inner--",
"--outer--",
].join("\r\n");
const markup = renderToStaticMarkup(<MultipartViewer data={bytes(body)} boundary="outer" />);
expect(markup).toContain('data-viewer="text"');
expect(markup).toContain(">deep</pre>");
expect(markup).not.toContain("--inner");
});
test("shows multipart nested past the limit raw, with a note", () => {
// Six levels, each a multipart part holding the next, the innermost a text part
let body = ["--l6", "Content-Type: text/plain", "", "innermost", "--l6--"].join("\r\n");
for (let level = 5; level >= 1; level--) {
body = [
`--l${level}`,
`Content-Type: multipart/mixed; boundary=l${level + 1}`,
"",
body,
`--l${level}--`,
].join("\r\n");
}
const markup = renderToStaticMarkup(<MultipartViewer data={bytes(body)} boundary="l1" />);
expect(markup).toContain(`nested more than ${MAX_MULTIPART_DEPTH} levels deep`);
// Level five is shown as its raw text, so its own delimiters are still in it
expect(markup).toContain('data-viewer="text" data-language="text"');
expect(markup).toContain("--l5");
expect(markup).not.toContain(">innermost</pre>");
});
test("shows a binary part as a hexdump", () => {
const head = bytes("--b\r\nContent-Type: application/octet-stream\r\n\r\n");
const tail = bytes("\r\n--b--");
const data = new Uint8Array([...head, 0x00, 0x01, 0xff, 0xfe, ...tail]);
const markup = renderToStaticMarkup(<MultipartViewer data={data} boundary="b" />);
expect(markup).toContain('data-viewer="hexdump">4 bytes');
});
test("notes a body cut off before its closing delimiter", () => {
const markup = renderToStaticMarkup(
<MultipartViewer data={bytes("preamble\r\n--b\r\n\r\npartial")} boundary="b" />,
);
expect(markup).toContain("ends without its closing boundary");
expect(markup).toContain(">partial</pre>");
});
});
@@ -1,12 +1,24 @@
import { type MultipartPart, parseMultipart } from "@mjackson/multipart-parser";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { languageFromContentType } from "../../lib/contentType";
import {
boundaryFromContentType,
headerValue,
type HttpMessage,
isProbablyBinary,
type MultipartParseResult,
type MultipartPart,
parseHttpMessage,
parseMultipart,
} from "../../lib/multipart";
import { Banner } from "../Banner";
import { HeadersTable } from "../HeadersTable";
import { Icon } from "../Icon";
import { HttpStatusTagRaw } from "../tags/HttpStatusTag";
import { TabContent, type TabContentProps, Tabs } from "../tabs/Tabs";
import { AudioViewer } from "./AudioViewer";
import { CsvViewer } from "./CsvViewer";
import { HexdumpViewer } from "./HexdumpViewer";
import { ImageViewer } from "./ImageViewer";
import { SvgViewer } from "./SvgViewer";
import { TextViewer, type TextViewerProps } from "./TextViewer";
@@ -15,6 +27,7 @@ import { WebPageViewer } from "./WebPageViewer";
export interface MultipartViewerProps {
data: Uint8Array;
/** The boundary, bare or as cut from a `Content-Type` header (quotes and later parameters are dropped) */
boundary: string;
idPrefix?: string;
/** Renders a PDF part, which shows as text without it */
@@ -24,35 +37,47 @@ export interface MultipartViewerProps {
ErrorBoundary?: TabContentProps["ErrorBoundary"];
}
export function MultipartViewer({
/** How many multipart levels show as tabs; a part nested deeper shows raw, with a note */
export const MAX_MULTIPART_DEPTH = 4;
type ViewerOptions = Pick<
MultipartViewerProps,
"renderPdf" | "textViewerProps" | "ErrorBoundary"
> & {
/** The multipart levels shown so far, this one included */
depth: number;
};
export function MultipartViewer(props: MultipartViewerProps) {
return <MultipartParts {...props} depth={1} />;
}
function MultipartParts({
data,
boundary,
idPrefix = "multipart",
renderPdf,
textViewerProps,
ErrorBoundary,
}: MultipartViewerProps) {
const parseResult = useMemo(() => {
...options
}: MultipartViewerProps & { depth: number }) {
// Parsed once per bytes and boundary, however often this renders
const parseResult = useMemo<
{ result: MultipartParseResult; error: null } | { result: null; error: string }
>(() => {
try {
const maxFileSize = 1024 * 1024 * 10; // 10MB
const parsed = parseMultipart(data, { boundary, maxFileSize });
const parts = Array.from(parsed);
return { parts, error: null };
return { result: parseMultipart(data, boundary), error: null };
} catch (err) {
return { parts: [], error: err instanceof Error ? err.message : String(err) };
return { result: null, error: err instanceof Error ? err.message : String(err) };
}
}, [data, boundary]);
const { parts, error } = parseResult;
if (error) {
if (parseResult.error != null) {
return (
<Banner color="danger" className="m-3">
Failed to parse multipart data: {error}
Failed to parse multipart data: {parseResult.error}
</Banner>
);
}
const { parts, complete } = parseResult.result;
if (parts.length === 0) {
return (
<Banner color="info" className="m-3">
@@ -61,20 +86,21 @@ export function MultipartViewer({
);
}
return (
const tabs = (
<Tabs
addBorders
label="Multipart"
layout="horizontal"
tabListClassName="border-r border-r-border -ml-3"
tabs={parts.map((part, i) => ({
label: part.name ?? "",
label: partLabel(part, i),
value: tabValue(part, i),
rightSlot:
part.filename && part.headers.contentType.mediaType?.startsWith("image/") ? (
part.filename && part.mediaType?.startsWith("image/") ? (
<div className="h-5 w-5 overflow-auto flex items-center justify-end">
<ImageViewer
data={part.arrayBuffer}
data={toArrayBuffer(part.body)}
mimeType={part.mediaType}
className="ml-auto w-auto rounded-sm overflow-hidden"
/>
</div>
@@ -86,59 +112,182 @@ export function MultipartViewer({
{parts.map((part, i) => (
<TabContent
// oxlint-disable-next-line react/no-array-index-key -- Nothing else to key on
key={idPrefix + part.name + i}
key={idPrefix + (part.name ?? "") + i}
value={tabValue(part, i)}
className="pl-3 pt-0!"
ErrorBoundary={ErrorBoundary}
ErrorBoundary={options.ErrorBoundary}
>
<Part part={part} renderPdf={renderPdf} textViewerProps={textViewerProps} />
<Part part={part} idPrefix={`${idPrefix}.${i}`} {...options} />
</TabContent>
))}
</Tabs>
);
if (complete) return tabs;
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<Banner color="warning" className="mb-2 mr-3 text-sm">
The body ends without its closing boundary; showing the parts that arrived.
</Banner>
{tabs}
</div>
);
}
function Part({
part,
idPrefix,
...options
}: { part: MultipartPart; idPrefix: string } & ViewerOptions) {
if (part.mediaType === "application/http") {
const message = parseHttpMessage(part.body);
if (message != null) {
return <HttpMessagePart message={message} idPrefix={idPrefix} {...options} />;
}
}
return (
<PartBody body={part.body} contentType={part.contentType} idPrefix={idPrefix} {...options} />
);
}
/** An `application/http` part, as in Google and OData batches: an HTTP message of its own */
function HttpMessagePart({
message,
idPrefix,
...options
}: { message: HttpMessage; idPrefix: string } & ViewerOptions) {
return (
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)] gap-y-2">
<div className="font-mono text-sm pt-1 truncate">
{message.status != null ? (
<HttpStatusTagRaw
status={message.status}
statusReason={message.statusReason}
showReason
/>
) : (
<span>
<span className="text-info">{message.method}</span> {message.url}
</span>
)}
</div>
<HeadersTable title="Headers" headers={message.headers} labelColor="primary" defaultOpen />
<PartBody
body={message.body}
contentType={headerValue(message.headers, "content-type")}
idPrefix={idPrefix}
{...options}
/>
</div>
);
}
/** A body by its own content type: the viewer the response pane would pick for it */
function PartBody({
body,
contentType,
idPrefix,
...options
}: {
body: Uint8Array;
contentType: string | null;
idPrefix: string;
} & ViewerOptions) {
const mediaType = contentType?.split(";")[0]?.trim().toLowerCase() || null;
if (body.length === 0) {
return <div className="text-text-subtlest text-sm italic pt-1">Empty</div>;
}
const nestedBoundary = mediaType?.startsWith("multipart/")
? boundaryFromContentType(contentType)
: null;
if (nestedBoundary != null) {
if (options.depth < MAX_MULTIPART_DEPTH) {
// The nested body is a view into the outer one's bytes, parsed only when shown
return (
<MultipartParts
{...options}
data={body}
boundary={nestedBoundary}
idPrefix={idPrefix}
depth={options.depth + 1}
/>
);
}
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<Banner color="info" className="mb-2 mr-3 text-sm">
Multipart nested more than {MAX_MULTIPART_DEPTH} levels deep; shown raw.
</Banner>
<LeafBody body={body} contentType={null} mediaType={null} raw {...options} />
</div>
);
}
return <LeafBody body={body} contentType={contentType} mediaType={mediaType} {...options} />;
}
/** A body that isn't multipart, decoded only here, where a viewer needs its text */
function LeafBody({
body,
contentType,
mediaType,
raw,
renderPdf,
textViewerProps,
}: { part: MultipartPart } & Pick<MultipartViewerProps, "renderPdf" | "textViewerProps">) {
const mimeType = part.headers.contentType.mediaType ?? null;
const contentTypeHeader = part.headers.get("content-type");
}: {
body: Uint8Array;
contentType: string | null;
mediaType: string | null;
/** Text or a hexdump, whatever the content type says */
raw?: boolean;
} & ViewerOptions) {
const { content, detectedLanguage, binary } = useMemo(() => {
const binary = isBinaryPart(mediaType, body);
const content = new TextDecoder().decode(body);
const detectedLanguage = raw ? "text" : languageFromContentType(contentType, content);
return { content, detectedLanguage, binary };
}, [body, contentType, mediaType, raw]);
const { uint8Array, content, detectedLanguage } = useMemo(() => {
const uint8Array = new Uint8Array(part.arrayBuffer);
const content = new TextDecoder().decode(part.arrayBuffer);
const detectedLanguage = languageFromContentType(contentTypeHeader, content);
return { uint8Array, content, detectedLanguage };
}, [part, contentTypeHeader]);
if (raw) {
return binary ? (
<HexdumpViewer data={body} renderEditor={textViewerProps?.renderEditor} />
) : (
<TextViewer {...textViewerProps} text={content} language="text" stateKey={null} />
);
}
if (mimeType?.match(/^image\/svg/i)) {
if (mediaType?.match(/^image\/svg/i)) {
return <SvgViewer text={content} className="pb-2" />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={part.arrayBuffer} className="pb-2" />;
if (mediaType?.match(/^image/i)) {
return <ImageViewer data={toArrayBuffer(body)} mimeType={mediaType} className="pb-2" />;
}
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={uint8Array} />;
if (mediaType?.match(/^audio/i)) {
return <AudioViewer data={body} mimeType={mediaType} />;
}
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={uint8Array} />;
if (mediaType?.match(/^video/i)) {
return <VideoViewer data={body} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
if (mediaType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={content} className="bg-primary h-10 w-10" />;
}
if (mimeType?.match(/^text\/html/i) || detectedLanguage === "html") {
if (mediaType?.match(/^text\/html/i) || (!binary && detectedLanguage === "html")) {
return <WebPageViewer html={content} />;
}
if (mimeType?.match(/pdf/i) && renderPdf != null) {
return renderPdf(uint8Array);
if (mediaType?.match(/pdf/i) && renderPdf != null) {
return renderPdf(body);
}
if (binary) {
return <HexdumpViewer data={body} renderEditor={textViewerProps?.renderEditor} />;
}
return (
@@ -146,6 +295,25 @@ function Part({
);
}
/** Text types are shown as text even with odd bytes; anything else is binary if its bytes say so */
function isBinaryPart(mediaType: string | null, body: Uint8Array): boolean {
if (
mediaType != null &&
/^text\/|json|xml|javascript|yaml|csv|x-www-form-urlencoded/.test(mediaType)
) {
return false;
}
return isProbablyBinary(body);
}
function partLabel(part: MultipartPart, i: number): string {
return part.name || part.filename || part.contentId || `Part ${i + 1}`;
}
function tabValue(part: MultipartPart, i: number) {
return `${part.name ?? ""}::${i}`;
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.slice().buffer;
}
+34
View File
@@ -0,0 +1,34 @@
// Multipart bodies for the parser and viewer tests
/** Gmail's batch response, as captured: a blank line, then one `application/http` part */
export const GMAIL_BATCH_BOUNDARY = "batch_8Js-Xj0pJXhmxHz6uTLe9MxOxKn8BPHl";
export const GMAIL_BATCH_CONTENT_TYPE = `multipart/mixed; boundary=${GMAIL_BATCH_BOUNDARY}`;
export const GMAIL_BATCH_JSON = [
"{",
' "id": "IMPORTANT",',
' "name": "IMPORTANT",',
' "messageListVisibility": "hide",',
' "labelListVisibility": "labelShow",',
' "type": "system",',
' "messagesTotal": 23545,',
' "messagesUnread": 12,',
' "threadsTotal": 19203,',
' "threadsUnread": 10',
"}",
].join("\n");
export const GMAIL_BATCH_BODY = [
"",
`--${GMAIL_BATCH_BOUNDARY}`,
"Content-Type: application/http",
"Content-ID: response-gtlr_43499",
"",
"HTTP/1.1 200 OK",
"Content-Type: application/json; charset=UTF-8",
"Vary: Origin",
"Vary: X-Origin",
"Vary: Referer",
"",
GMAIL_BATCH_JSON,
`--${GMAIL_BATCH_BOUNDARY}--`,
"",
].join("\r\n");
+239
View File
@@ -0,0 +1,239 @@
import { describe, expect, test } from "vite-plus/test";
import {
boundaryFromContentType,
headerValue,
isProbablyBinary,
normalizeBoundary,
parseHttpMessage,
parseMultipart,
} from "./multipart";
import {
GMAIL_BATCH_BODY,
GMAIL_BATCH_BOUNDARY,
GMAIL_BATCH_CONTENT_TYPE,
} from "./multipart.fixtures";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const bytes = (s: string) => encoder.encode(s);
const text = (b: Uint8Array) => decoder.decode(b);
describe("parseMultipart", () => {
test("parses Gmail's batch response, skipping the blank line before the first boundary", () => {
const boundary = boundaryFromContentType(GMAIL_BATCH_CONTENT_TYPE)!;
const { parts, complete } = parseMultipart(bytes(GMAIL_BATCH_BODY), boundary);
expect(complete).toBe(true);
expect(parts).toHaveLength(1);
const part = parts[0]!;
expect(part.mediaType).toBe("application/http");
expect(part.contentId).toBe("response-gtlr_43499");
const message = parseHttpMessage(part.body)!;
expect(message.status).toBe(200);
expect(message.statusReason).toBe("OK");
expect(message.headers.filter((h) => h.name === "Vary").map((h) => h.value)).toEqual([
"Origin",
"X-Origin",
"Referer",
]);
expect(headerValue(message.headers, "content-type")).toBe("application/json; charset=UTF-8");
expect(JSON.parse(text(message.body))).toMatchObject({ id: "IMPORTANT", messagesTotal: 23545 });
});
test("skips a text preamble and ignores the epilogue", () => {
const body = [
"This is a multi-part message in MIME format.",
"--b",
'Content-Disposition: form-data; name="a"',
"",
"one",
"--b",
'Content-Disposition: form-data; name="b"',
"",
"two",
"--b--",
"This is the epilogue. --b",
].join("\r\n");
const { parts, complete } = parseMultipart(bytes(body), "b");
expect(complete).toBe(true);
expect(parts.map((p) => [p.name, text(p.body)])).toEqual([
["a", "one"],
["b", "two"],
]);
});
test("shows the parts that arrived when the closing delimiter is missing", () => {
const body = ["--b", "Content-Type: text/plain", "", "one", "--b", "", "two cut sh"].join(
"\r\n",
);
const { parts, complete } = parseMultipart(bytes(body), "b");
expect(complete).toBe(false);
expect(parts.map((p) => text(p.body))).toEqual(["one", "two cut sh"]);
expect(parts[1]!.headers).toEqual([]);
});
test("tolerates a missing final line break and LF-only line endings", () => {
const body = ["--b", "Content-Type: text/plain", "", "one", "--b--"].join("\n");
const { parts, complete } = parseMultipart(bytes(body), "b");
expect(complete).toBe(true);
expect(text(parts[0]!.body)).toBe("one");
});
test("parses a multipart part nested inside a part", () => {
const inner = ["--inner", "Content-Type: text/plain", "", "deep", "--inner--"].join("\r\n");
const body = [
"--outer",
'Content-Type: multipart/alternative; boundary="inner"',
"",
inner,
"--outer",
"Content-Type: text/plain",
"",
"shallow",
"--outer--",
].join("\r\n");
const outer = parseMultipart(bytes(body), "outer");
expect(outer.parts).toHaveLength(2);
const nested = outer.parts[0]!;
expect(nested.mediaType).toBe("multipart/alternative");
const innerResult = parseMultipart(nested.body, boundaryFromContentType(nested.contentType)!);
expect(innerResult.complete).toBe(true);
expect(text(innerResult.parts[0]!.body)).toBe("deep");
expect(text(outer.parts[1]!.body)).toBe("shallow");
});
test("keeps a binary part's bytes exactly", () => {
const png = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff, 0x0d, 0x0a,
]);
const head = bytes(
[
"--b",
'Content-Disposition: form-data; name="file"; filename="a.bin"',
"Content-Type: application/octet-stream",
"",
"",
].join("\r\n"),
);
const tail = bytes("\r\n--b--\r\n");
const body = new Uint8Array([...head, ...png, ...tail]);
const { parts } = parseMultipart(body, "b");
expect(parts[0]!.filename).toBe("a.bin");
expect(Array.from(parts[0]!.body)).toEqual(Array.from(png));
expect(isProbablyBinary(parts[0]!.body)).toBe(true);
});
test("handles quoted boundaries with special characters", () => {
const boundary = "a'b(c)+_,-./:=? d";
const contentType = `multipart/form-data; boundary="${boundary}"; charset=utf-8`;
expect(boundaryFromContentType(contentType)).toBe(boundary);
// What the panes cut from the header after `boundary=`
expect(normalizeBoundary(`"${boundary}"; charset=utf-8`)).toBe(boundary);
expect(normalizeBoundary("simple; charset=utf-8")).toBe("simple");
const body = [`--${boundary}`, "", "x", `--${boundary}--`].join("\r\n");
const { parts } = parseMultipart(bytes(body), `"${boundary}"`);
expect(parts.map((p) => text(p.body))).toEqual(["x"]);
});
test("does not take a longer boundary for this one", () => {
const body = ["--b", "", "x", "--bb", "still x", "--b--"].join("\r\n");
const { parts } = parseMultipart(bytes(body), "b");
expect(parts.map((p) => text(p.body))).toEqual(["x\r\n--bb\r\nstill x"]);
});
test("takes a line for a delimiter only when the whole line is one", () => {
const body = [
"--b",
"",
"x",
"--b-suffix",
"--b--suffix",
"still x",
"--b ",
"",
"y",
"--b-- ",
"epilogue",
].join("\r\n");
const { parts, complete } = parseMultipart(bytes(body), "b");
expect(complete).toBe(true);
expect(parts.map((p) => text(p.body))).toEqual([
"x\r\n--b-suffix\r\n--b--suffix\r\nstill x",
"y",
]);
});
test("reads quoted and extended disposition parameters", () => {
const body = [
"--b",
`Content-Disposition: form-data; name="a;b"; filename*=UTF-8''r%C3%A9sum%C3%A9.txt`,
"",
"x",
"--b--",
].join("\r\n");
const { parts } = parseMultipart(bytes(body), "b");
expect(parts[0]!.name).toBe("a;b");
expect(parts[0]!.filename).toBe("résumé.txt");
});
test("reads UTF-8 names, quoted semicolons and a filename in its declared charset", () => {
const body = [
"--b",
'Content-Disposition: form-data; filename="a; name=wrong"; name="café"',
"",
"x",
"--b",
"Content-Disposition: attachment; filename*=ISO-8859-1''caf%E9.txt",
"",
"y",
"--b--",
].join("\r\n");
const { parts } = parseMultipart(bytes(body), "b");
expect(parts[0]!.name).toBe("café");
expect(parts[0]!.filename).toBe("a; name=wrong");
expect(parts[1]!.filename).toBe("café.txt");
});
test("keeps every byte of a part cut off before its delimiter", () => {
const { parts } = parseMultipart(bytes("--b\r\n\r\nhello\r\n"), "b");
expect(text(parts[0]!.body)).toBe("hello\r\n");
});
test("keeps a quoted boundary's leading space, however often it's normalized", () => {
const contentType = 'multipart/mixed; boundary=" b"';
const boundary = boundaryFromContentType(contentType)!;
expect(boundary).toBe(" b");
expect(normalizeBoundary(boundary)).toBe(" b");
const { parts } = parseMultipart(bytes("-- b\r\n\r\nx\r\n-- b--"), boundary);
expect(parts.map((p) => text(p.body))).toEqual(["x"]);
});
test("throws when the boundary never appears", () => {
expect(() => parseMultipart(bytes("nothing here"), "b")).toThrow(/No "--b" delimiter/);
});
test("parses the same bytes once", () => {
const data = bytes(GMAIL_BATCH_BODY);
expect(parseMultipart(data, GMAIL_BATCH_BOUNDARY)).toBe(
parseMultipart(data, GMAIL_BATCH_BOUNDARY),
);
});
});
describe("parseHttpMessage", () => {
test("parses a request line, as in an OData batch", () => {
const message = parseHttpMessage(
bytes("GET /people(1) HTTP/1.1\r\nAccept: application/json\r\n\r\n"),
)!;
expect(message.method).toBe("GET");
expect(message.url).toBe("/people(1)");
expect(message.status).toBeNull();
expect(message.body).toHaveLength(0);
});
test("returns null for something that isn't HTTP", () => {
expect(parseHttpMessage(bytes("hello world"))).toBeNull();
});
});
+386
View File
@@ -0,0 +1,386 @@
/**
* A forgiving multipart parser for viewing bodies, not for validating them. It follows RFC 2046
* but shows whatever it can: a preamble before the first delimiter and an epilogue after the
* last are skipped, LF line endings are accepted as well as CRLF, and a body cut off before its
* closing delimiter still gives the parts that arrived.
*/
export interface MultipartHeader {
name: string;
value: string;
}
export interface MultipartPart {
headers: MultipartHeader[];
/** The part's `Content-Type` header as sent, or null when it has none */
contentType: string | null;
/** The lowercased type and subtype of `contentType`, like `application/json` */
mediaType: string | null;
/** The `name` parameter of `Content-Disposition` */
name: string | null;
/** The `filename` parameter of `Content-Disposition` */
filename: string | null;
/** The `Content-ID` header, without angle brackets */
contentId: string | null;
body: Uint8Array;
}
export interface MultipartParseResult {
parts: MultipartPart[];
/** Whether the closing `--boundary--` delimiter was found */
complete: boolean;
}
export interface HttpMessage {
/** `HTTP/1.1 200 OK` or `GET /path HTTP/1.1`, as sent */
startLine: string;
/** The status code, for a response */
status: number | null;
/** The reason phrase, for a response */
statusReason: string | null;
/** The method, for a request */
method: string | null;
/** The target, for a request */
url: string | null;
headers: MultipartHeader[];
body: Uint8Array;
}
const CR = 0x0d;
const LF = 0x0a;
const DASH = 0x2d;
const SPACE = 0x20;
const TAB = 0x09;
const encoder = new TextEncoder();
const latin1 = new TextDecoder("latin1");
const utf8 = new TextDecoder("utf-8", { fatal: true });
/** Results already parsed, by the bytes and boundary they came from, for as long as those bytes live */
const parsed = new WeakMap<Uint8Array, Map<string, MultipartParseResult>>();
/**
* The boundary from a `Content-Type` header, or from a value cut out of one, without quotes or
* the parameters that follow it. Null when there is none.
*/
export function boundaryFromContentType(contentType: string | null | undefined): string | null {
if (contentType == null) return null;
const boundary = headerParams(contentType).get("boundary");
return boundary == null || boundary === "" ? null : boundary;
}
/**
* A boundary as it appears after `--`: surrounding quotes removed, escapes undone, and anything
* after an unquoted `;` dropped, so a value like `"a b"; charset=x` gives `a b`. A boundary can't
* hold `"` or `;`, so normalizing one that already is gives it back unchanged, leading spaces and
* all.
*/
export function normalizeBoundary(boundary: string | null | undefined): string | null {
if (boundary == null) return null;
let value = boundary;
if (value.trimStart().startsWith('"')) {
const end = /^"((?:[^"\\]|\\.)*)"/.exec(value.trimStart());
value = end != null ? end[1]!.replace(/\\(.)/g, "$1") : value.trimStart().slice(1);
} else {
// Trailing whitespace isn't part of a boundary (RFC 2046)
value = value.split(";")[0]!.trimEnd();
}
return value === "" ? null : value;
}
/** Parses a multipart body, made once for the same bytes and boundary */
export function parseMultipart(data: Uint8Array, rawBoundary: string): MultipartParseResult {
const boundary = normalizeBoundary(rawBoundary) ?? rawBoundary;
let byBoundary = parsed.get(data);
const cached = byBoundary?.get(boundary);
if (cached != null) return cached;
const result = parseMultipartUncached(data, boundary);
if (byBoundary == null) {
byBoundary = new Map();
parsed.set(data, byBoundary);
}
byBoundary.set(boundary, result);
return result;
}
function parseMultipartUncached(data: Uint8Array, boundary: string): MultipartParseResult {
const delimiter = encoder.encode(`--${boundary}`);
// The first delimiter starts the body or a line; anything before it is preamble
let start = findDelimiter(data, delimiter, 0);
if (start < 0) {
throw new Error(`No "--${boundary}" delimiter found`);
}
const parts: MultipartPart[] = [];
for (;;) {
let pos = start + delimiter.length;
// `--boundary--` closes the body; whatever follows is epilogue
if (data[pos] === DASH && data[pos + 1] === DASH) {
return { parts, complete: true };
}
// Transport padding, then the line break that ends the delimiter line
while (data[pos] === SPACE || data[pos] === TAB) pos++;
if (data[pos] === CR) pos++;
if (data[pos] === LF) pos++;
if (pos >= data.length) {
return { parts, complete: false };
}
const next = findDelimiter(data, delimiter, pos);
if (next < 0) {
// Cut off before the next delimiter: the rest is the last part, as far as it got
parts.push(parsePart(data.subarray(pos)));
return { parts, complete: false };
}
// The line break before a delimiter belongs to the delimiter, not the part
const end = next === pos ? pos : lineStart(data, next);
parts.push(parsePart(data.subarray(pos, end)));
start = next;
}
}
/**
* Where the next delimiter at the start of a line begins, at or after `from`, or -1. A match must
* be followed by `--`, whitespace, a line break or the end of the data, so a longer boundary that
* merely starts with this one isn't taken for it.
*/
function findDelimiter(data: Uint8Array, delimiter: Uint8Array, from: number): number {
let i = data.indexOf(delimiter[0]!, from);
while (i >= 0 && i + delimiter.length <= data.length) {
if (
(i === 0 || data[i - 1] === LF) &&
matchesAt(data, delimiter, i) &&
endsDelimiter(data, i + delimiter.length)
) {
return i;
}
i = data.indexOf(delimiter[0]!, i + 1);
}
return -1;
}
function matchesAt(data: Uint8Array, needle: Uint8Array, at: number): boolean {
for (let j = 0; j < needle.length; j++) {
if (data[at + j] !== needle[j]) return false;
}
return true;
}
/**
* Whether the rest of the line after a boundary completes a delimiter: an optional `--` for the
* closing one, optional transport padding, then a line break or the end of the data. So with
* boundary `b`, neither `--b-suffix` nor `--b--suffix` is a delimiter.
*/
function endsDelimiter(data: Uint8Array, at: number): boolean {
let pos = at;
if (data[pos] === DASH && data[pos + 1] === DASH) pos += 2;
while (data[pos] === SPACE || data[pos] === TAB) pos++;
if (pos >= data.length) return true;
if (data[pos] === LF) return true;
return data[pos] === CR && (pos + 1 >= data.length || data[pos + 1] === LF);
}
/** The start of the line break before `at` (CRLF or LF), or `at` when there is none */
function lineStart(data: Uint8Array, at: number): number {
if (at > 0 && data[at - 1] === LF) {
return at > 1 && data[at - 2] === CR ? at - 2 : at - 1;
}
return at;
}
function parsePart(bytes: Uint8Array): MultipartPart {
const { headers, body } = splitHeaders(bytes);
const contentType = headerValue(headers, "content-type");
const disposition = headerParams(headerValue(headers, "content-disposition") ?? "");
const contentId = headerValue(headers, "content-id");
const extendedFilename = disposition.get("filename*");
return {
headers,
contentType,
mediaType: mediaTypeOf(contentType),
name: disposition.get("name") ?? null,
filename:
extendedFilename != null
? decodeExtendedValue(extendedFilename)
: (disposition.get("filename") ?? null),
contentId: contentId == null ? null : contentId.replace(/^<(.*)>$/, "$1"),
body,
};
}
/**
* Splits a header block from the body after it, at the first blank line. A part that starts with
* a blank line has no headers.
*/
function splitHeaders(bytes: Uint8Array): { headers: MultipartHeader[]; body: Uint8Array } {
let pos = 0;
const lines: string[] = [];
for (;;) {
const lf = bytes.indexOf(LF, pos);
const lineEnd = lf < 0 ? bytes.length : lf;
const contentEnd = lineEnd > pos && bytes[lineEnd - 1] === CR ? lineEnd - 1 : lineEnd;
if (contentEnd === pos) {
// A blank line ends the headers; without one, it was all headers and no body
pos = lf < 0 ? bytes.length : lf + 1;
break;
}
const line = decodeHeaderLine(bytes.subarray(pos, contentEnd));
if (lines.length === 0 && !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+\s*:/.test(line)) {
// Not a header, so a part sent without its blank line: all of it is body
return { headers: [], body: bytes };
}
lines.push(line);
if (lf < 0) {
pos = bytes.length;
break;
}
pos = lf + 1;
}
const headers: MultipartHeader[] = [];
for (const line of lines) {
const last = headers[headers.length - 1];
if ((line.startsWith(" ") || line.startsWith("\t")) && last != null) {
// A folded line continues the header before it
last.value = `${last.value} ${line.trim()}`;
continue;
}
const colon = line.indexOf(":");
if (colon <= 0) continue;
headers.push({ name: line.slice(0, colon).trim(), value: line.slice(colon + 1).trim() });
}
return { headers, body: bytes.subarray(pos) };
}
/**
* Parses an `application/http` part: a request or response line, its headers and its body. Null
* when the bytes don't start with one.
*/
export function parseHttpMessage(bytes: Uint8Array): HttpMessage | null {
// Leading blank lines are tolerated, as HTTP/1.1 servers do
let start = 0;
while (bytes[start] === CR || bytes[start] === LF) start++;
const lf = bytes.indexOf(LF, start);
const lineEnd = lf < 0 ? bytes.length : lf;
const startLine = latin1
.decode(
bytes.subarray(start, lineEnd > start && bytes[lineEnd - 1] === CR ? lineEnd - 1 : lineEnd),
)
.trim();
const response = /^(HTTP\/\d(?:\.\d)?)\s+(\d{3})(?:\s+(.*))?$/i.exec(startLine);
const request = /^([A-Z]+)\s+(\S+)(?:\s+HTTP\/\d(?:\.\d)?)?$/.exec(startLine);
if (response == null && request == null) return null;
const rest = lf < 0 ? new Uint8Array() : bytes.subarray(lf + 1);
const { headers, body } = splitHeaders(rest);
return {
startLine,
status: response != null ? Number(response[2]) : null,
statusReason: response?.[3]?.trim() || null,
method: response == null ? (request?.[1] ?? null) : null,
url: response == null ? (request?.[2] ?? null) : null,
headers,
body,
};
}
/** A header line as UTF-8, as browsers send form field names, or Latin-1 when it isn't valid UTF-8 */
function decodeHeaderLine(bytes: Uint8Array): string {
try {
return utf8.decode(bytes);
} catch {
return latin1.decode(bytes);
}
}
export function headerValue(headers: MultipartHeader[], name: string): string | null {
const lower = name.toLowerCase();
return headers.find((h) => h.name.toLowerCase() === lower)?.value ?? null;
}
function mediaTypeOf(contentType: string | null): string | null {
if (contentType == null) return null;
const essence = contentType.split(";")[0]!.trim().toLowerCase();
return essence === "" ? null : essence;
}
/**
* The parameters after a header's first `;`, like `form-data; name="a"; filename="b.txt"`, by
* lowercased name and unquoted. A quoted value may hold `;` and `=` without ending it.
*/
function headerParams(header: string): Map<string, string> {
const params = new Map<string, string>();
let i = header.indexOf(";");
if (i < 0) return params;
while (i < header.length) {
// Past the `;` and whitespace, then the name up to `=` or the next `;`
while (i < header.length && (header[i] === ";" || header[i] === " " || header[i] === "\t")) i++;
const nameStart = i;
while (i < header.length && header[i] !== "=" && header[i] !== ";") i++;
const name = header.slice(nameStart, i).trim().toLowerCase();
if (header[i] !== "=") continue;
i++;
while (header[i] === " " || header[i] === "\t") i++;
let value = "";
if (header[i] === '"') {
i++;
while (i < header.length && header[i] !== '"') {
if (header[i] === "\\" && i + 1 < header.length) i++;
value += header[i];
i++;
}
// Past the closing quote and anything up to the next parameter
while (i < header.length && header[i] !== ";") i++;
} else {
const valueStart = i;
while (i < header.length && header[i] !== ";") i++;
value = header.slice(valueStart, i).trim();
}
if (name !== "" && !params.has(name)) params.set(name, value);
}
return params;
}
/** An RFC 5987 extended value, `charset'language'percent-encoded`, decoded in its charset */
function decodeExtendedValue(value: string): string {
const ext = /^([^']*)'[^']*'(.*)$/.exec(value);
if (ext == null) return value;
const encoded = ext[2]!;
const bytes: number[] = [];
for (let i = 0; i < encoded.length; i++) {
const hex = encoded.slice(i + 1, i + 3);
if (encoded[i] === "%" && /^[0-9a-f]{2}$/i.test(hex)) {
bytes.push(parseInt(hex, 16));
i += 2;
} else {
bytes.push(encoded.charCodeAt(i) & 0xff);
}
}
try {
return new TextDecoder(ext[1]!.trim() || "utf-8").decode(new Uint8Array(bytes));
} catch {
return encoded;
}
}
/** Whether bytes look binary: not valid UTF-8, or holding NUL or other control bytes */
export function isProbablyBinary(bytes: Uint8Array): boolean {
const sample = bytes.subarray(0, 8192);
for (const b of sample) {
if (b === 0) return true;
if (b < 0x20 && b !== TAB && b !== LF && b !== CR && b !== 0x0c && b !== 0x1b) return true;
}
try {
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
return false;
} catch {
return true;
}
}