add a factory for sidecar proxy routers
eight sidecars hand-rolled the same port capture — byte-identical once the app name is normalised — and six repeated the same auth-and-forward router. createSidecarProxy collapses both into one call and covers the variants the others need: a ws:// url for music and vault, an onRegister hook for opencode. the wallet adopts it first: two files become one, 54 lines of router become 16, and hono no longer needs a side-effect import to capture the port. the no-body-parsing, no-body-logging rule moves into the factory with its rationale, since that restraint is what keeps unlock passphrases and macaroons out of the platform process. costs 31 net lines today and pays back from the second adopter on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import type { Hono } from 'hono';
|
||||
import type { HonoVariables } from '../create-router';
|
||||
import { createRouter } from '../create-router';
|
||||
import * as sidecar from '../sidecar-registry';
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
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 }: SidecarProxyParams): SidecarProxy {
|
||||
let serverPort: number | null = null;
|
||||
|
||||
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}`;
|
||||
|
||||
const method = ctx.req.method;
|
||||
const headers: Record<string, string> = {};
|
||||
const contentType = ctx.req.header('content-type');
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
// 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';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||
});
|
||||
} 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user