Files
platform/src/servers/sidecar/create-proxy.ts
T
pastilhasandClaude Opus 5 91898733a4 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>
2026-08-07 03:22:37 +00:00

130 lines
6.2 KiB
TypeScript

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.
//
// A sidecar binds an EPHEMERAL loopback port and announces it over the registration socket as a
// `<name>:server` event. The platform remembers that port and forwards `<prefix>/*` to it. Before this
// factory existed each sidecar hand-rolled the same two files — eight copies of the port capture, which
// were byte-identical once the app name was normalised away, and six copies of the forwarder.
//
// WHAT THE PLATFORM DELIBERATELY DOES NOT DO HERE. It authenticates the owner (userMiddleware upstream)
// and forwards. It does not parse, inspect, log or retain request bodies. That restraint is load-bearing:
// the platform process is long-lived, restarts on every deploy and is the largest attack surface in the
// system, while the bodies passing through carry wallet unlock passphrases, macaroons and vault secrets.
// Keeping this layer structurally incapable of seeing them is the point. Note the absence of any body
// inspection below, and that the error path logs the target path ONLY.
//
// Sidecars own their own contracts. This file must never grow per-app logic — if a sidecar needs
// something extra at registration time it gets `onRegister`, not a branch in here.
export type SidecarProxyParams = {
/** Sidecar name, e.g. 'wallet'. The registration event is `<name>:server` and logs are tagged `[name]`. */
name: string;
/** Public mount path, e.g. '/api/wallet'. Stripped from the path before forwarding. */
prefix: string;
/** Extra work when the sidecar reports in. Rare — most sidecars only need the port remembered. */
onRegister?: (port: number) => void;
/**
* Per-request idle timeout, seconds. The main server drops a proxied connection after 60s with no bytes
* flowing, which is wrong for a sidecar route that thinks before it answers (a from-scratch music
* reindex holds the socket open for minutes). Applied to the whole prefix: the proxy must not know which
* of a sidecar's routes are slow.
*/
timeoutSeconds?: number;
};
export type SidecarProxy = {
/** Catch-all router to mount at `prefix`. Has no routes of its own by design. */
router: Hono<{ Variables: HonoVariables }>;
/** `http://127.0.0.1:<port>`, or null before the sidecar has reported in. */
getHttpUrl: () => string | null;
/** The same server as a `ws://` base, for sidecars whose sockets the platform relays. */
getWsUrl: () => string | null;
};
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;
serverPort = port;
console.log(`[${name}] sidecar registered on port ${port}`);
onRegister?.(port);
});
const getHttpUrl = (): string | null => (serverPort ? `http://127.0.0.1:${serverPort}` : null);
const getWsUrl = (): string | null => (serverPort ? `ws://127.0.0.1:${serverPort}` : null);
const router = createRouter();
router.all('/*', async (ctx) => {
const baseUrl = getHttpUrl();
if (!baseUrl) return ctx.text(`${name} sidecar not available`, 503);
const url = new URL(ctx.req.url);
const subpath = url.pathname.slice(prefix.length) || '/';
const target = `${baseUrl}${subpath}${url.search}`;
if (timeoutSeconds) {
// Bun passes the server as Hono's env. Older Bun has no per-request timeout; the request still
// completes, it just risks the default idle drop.
const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined;
try {
server?.timeout?.(ctx.req.raw, timeoutSeconds);
} catch {
/* no per-request timeout available */
}
}
const method = ctx.req.method;
const headers: Record<string, string> = {};
// Transport headers, forwarded for every sidecar because they are HTTP, not app knowledge: Range so a
// sidecar can answer 206 for media seeking, and If-None-Match so its ETag revalidation can return a
// cheap 304 instead of the whole body.
for (const header of ['content-type', 'range', 'if-none-match'] as const) {
const value = ctx.req.header(header);
if (value) headers[header] = value;
}
// The authenticated owner, so the sidecar can scope its data. The sidecar binds loopback only, so it
// trusts this header — which is exactly why nothing but this proxy may set it.
headers['X-Officer-User'] = String(ctx.get('user').id);
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, 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) });
return ctx.text(`${name} sidecar unreachable`, 502);
}
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
});
return { router, getHttpUrl, getWsUrl };
}