diff --git a/packages/ui/src/components/viewers/MultipartViewer.test.tsx b/packages/ui/src/components/viewers/MultipartViewer.test.tsx
new file mode 100644
index 000000000..72ec73721
--- /dev/null
+++ b/packages/ui/src/components/viewers/MultipartViewer.test.tsx
@@ -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 }) => (
+
+ {text}
+
+ ),
+}));
+vi.mock("./HexdumpViewer", () => ({
+ HexdumpViewer: ({ data }: { data: Uint8Array }) => (
+ {data.length} bytes
+ ),
+}));
+vi.mock("../tabs/Tabs", () => ({
+ Tabs: ({ tabs, children }: { tabs: { label: string }[]; children: ReactNode }) => (
+
+ {tabs.map((t) => (
+
+ {t.label}
+
+ ))}
+ {children}
+
+ ),
+ TabContent: ({ children }: { children: ReactNode }) => ,
+}));
+vi.mock("../HeadersTable", () => ({
+ HeadersTable: ({ headers }: { headers: { name: string; value: string }[] }) => (
+
+ {headers.map((h) => (
+
+ {h.name}: {h.value}
+
+ ))}
+
+ ),
+}));
+
+const bytes = (s: string) => new TextEncoder().encode(s);
+
+describe("MultipartViewer", () => {
+ test("renders Gmail's batch response as a nested HTTP response", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+ expect(markup).not.toContain("Failed to parse");
+ expect(markup).toContain(">response-gtlr_43499");
+ 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(""messagesTotal": 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( );
+ expect(markup).toContain('data-viewer="text"');
+ expect(markup).toContain(">deep");
+ 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( );
+ 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");
+ });
+
+ 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( );
+ expect(markup).toContain('data-viewer="hexdump">4 bytes');
+ });
+
+ test("notes a body cut off before its closing delimiter", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+ expect(markup).toContain("ends without its closing boundary");
+ expect(markup).toContain(">partial");
+ });
+});
diff --git a/packages/ui/src/components/viewers/MultipartViewer.tsx b/packages/ui/src/components/viewers/MultipartViewer.tsx
index 50a176537..0e5b5a42d 100644
--- a/packages/ui/src/components/viewers/MultipartViewer.tsx
+++ b/packages/ui/src/components/viewers/MultipartViewer.tsx
@@ -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 ;
+}
+
+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 (
- Failed to parse multipart data: {error}
+ Failed to parse multipart data: {parseResult.error}
);
}
+ const { parts, complete } = parseResult.result;
if (parts.length === 0) {
return (
@@ -61,20 +86,21 @@ export function MultipartViewer({
);
}
- return (
+ const tabs = (
({
- 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/") ? (
@@ -86,59 +112,182 @@ export function MultipartViewer({
{parts.map((part, i) => (
-
+
))}
);
+
+ if (complete) return tabs;
+ return (
+
+
+ The body ends without its closing boundary; showing the parts that arrived.
+
+ {tabs}
+
+ );
}
function Part({
part,
+ idPrefix,
+ ...options
+}: { part: MultipartPart; idPrefix: string } & ViewerOptions) {
+ if (part.mediaType === "application/http") {
+ const message = parseHttpMessage(part.body);
+ if (message != null) {
+ return ;
+ }
+ }
+ return (
+
+ );
+}
+
+/** 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 (
+
+
+ {message.status != null ? (
+
+ ) : (
+
+ {message.method} {message.url}
+
+ )}
+
+
+
+
+ );
+}
+
+/** 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 Empty
;
+ }
+
+ 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 (
+
+ );
+ }
+ return (
+
+
+ Multipart nested more than {MAX_MULTIPART_DEPTH} levels deep; shown raw.
+
+
+
+ );
+ }
+
+ return ;
+}
+
+/** 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) {
- 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 ? (
+
+ ) : (
+
+ );
+ }
- if (mimeType?.match(/^image\/svg/i)) {
+ if (mediaType?.match(/^image\/svg/i)) {
return ;
}
- if (mimeType?.match(/^image/i)) {
- return ;
+ if (mediaType?.match(/^image/i)) {
+ return ;
}
- if (mimeType?.match(/^audio/i)) {
- return ;
+ if (mediaType?.match(/^audio/i)) {
+ return ;
}
- if (mimeType?.match(/^video/i)) {
- return ;
+ if (mediaType?.match(/^video/i)) {
+ return ;
}
- if (mimeType?.match(/csv|tab-separated/i)) {
+ if (mediaType?.match(/csv|tab-separated/i)) {
return ;
}
- if (mimeType?.match(/^text\/html/i) || detectedLanguage === "html") {
+ if (mediaType?.match(/^text\/html/i) || (!binary && detectedLanguage === "html")) {
return ;
}
- if (mimeType?.match(/pdf/i) && renderPdf != null) {
- return renderPdf(uint8Array);
+ if (mediaType?.match(/pdf/i) && renderPdf != null) {
+ return renderPdf(body);
+ }
+
+ if (binary) {
+ return ;
}
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;
+}
diff --git a/packages/ui/src/lib/multipart.fixtures.ts b/packages/ui/src/lib/multipart.fixtures.ts
new file mode 100644
index 000000000..dd8e92ff3
--- /dev/null
+++ b/packages/ui/src/lib/multipart.fixtures.ts
@@ -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");
diff --git a/packages/ui/src/lib/multipart.test.ts b/packages/ui/src/lib/multipart.test.ts
new file mode 100644
index 000000000..95bd6bbe1
--- /dev/null
+++ b/packages/ui/src/lib/multipart.test.ts
@@ -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();
+ });
+});
diff --git a/packages/ui/src/lib/multipart.ts b/packages/ui/src/lib/multipart.ts
new file mode 100644
index 000000000..f726d7f30
--- /dev/null
+++ b/packages/ui/src/lib/multipart.ts
@@ -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>();
+
+/**
+ * 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 {
+ const params = new Map();
+ 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;
+ }
+}