stop parsing request bodies on sidecar proxy routes

every multipart upload through /api/<sidecar>/* arrived corrupted. bodyParser ran on
proxy routes and called parseBody for multipart, so hono cached a FormData on the
request; when the proxy then asked for the bytes hono re-serialised them from that
cache with a NEW boundary, while the proxy still forwarded the ORIGINAL content-type
header. header and body disagreed and the far side rejected it with
"Multipart: Unexpected end of form".

bodyParser now skips prefixes owned by createSidecarProxy, which register themselves
so a new sidecar cannot forget. the proxy also forwards the body as a stream instead
of buffering it, which drops the second in-memory copy of every upload.

note the bug report proposed skipping multipart in bodyParser outright; that would
have broken /upload, /file-browser upload and /bug-report, which do read a multipart
body from ctx.get('body').

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 03:22:37 +00:00
co-authored by Claude Opus 5
parent 50484521dd
commit 91898733a4
4 changed files with 228 additions and 5 deletions
+19 -5
View File
@@ -2,6 +2,7 @@ import type { Hono } from 'hono';
import type { HonoVariables } from '../create-router';
import { createRouter } from '../create-router';
import * as sidecar from '../sidecar-registry';
import { registerProxiedPrefix } from './proxied-prefixes';
// The shared auth-and-forward proxy every HTTP sidecar needs.
//
@@ -48,6 +49,10 @@ export type SidecarProxy = {
export function createSidecarProxy({ name, prefix, onRegister, timeoutSeconds }: SidecarProxyParams): SidecarProxy {
let serverPort: number | null = null;
// Tells bodyParser to keep its hands off this prefix's request bodies. Load-bearing for uploads, not an
// optimisation: see proxied-prefixes.ts.
registerProxiedPrefix(prefix);
sidecar.on(`${name}:server`, (msg) => {
const port = (msg as { port?: number }).port;
if (typeof port !== 'number') return;
@@ -95,13 +100,22 @@ export function createSidecarProxy({ name, prefix, onRegister, timeoutSeconds }:
const hasBody = method !== 'GET' && method !== 'HEAD';
// Forwarded as a STREAM, not a buffer. Buffering held the whole body in memory here on top of the copy
// the sidecar holds — two copies of a 4K video per upload — and bought nothing, since this layer never
// looks at the bytes. `duplex: 'half'` is required by the fetch spec whenever the body is a stream; it
// is absent from TypeScript's RequestInit, hence the widened type.
// This is only safe because bodyParser skips proxied prefixes: a body consumed upstream would arrive
// here as an already-locked stream.
const init: RequestInit & { duplex?: 'half' } = {
method,
headers,
body: hasBody ? ctx.req.raw.body : undefined,
duplex: 'half',
};
let upstream: Response;
try {
upstream = await fetch(target, {
method,
headers,
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
});
upstream = await fetch(target, init);
} catch (err) {
// Target path only — never the body, which may carry a passphrase or a credential.
console.error(`[${name}] proxy fetch failed`, { target, error: String(err) });