Files
platform/src/servers/sidecar/create-proxy.ts
T
pastilhasandClaude Opus 5 aaf0161620 put every http sidecar on the proxy factory
createSidecarProxy arrived with the wallet but nothing else moved onto it, so five sidecars
still carried their own copy of the same two files: a sidecar-server.ts that remembered a
port announced as `<name>:server`, and a router.ts that forwarded the subpath. Byte for
byte identical once the app name was normalised away — which is exactly what the factory's
own header said it existed to end.

headscale, transmission, invoiceshelf, slskd and music are now wallet-shaped: create the
proxy, export the router and the URL getter. 386 lines deleted against 163 added, and the
five feature directories go from ~70 lines each to ~18.

Two deviations were real and moved INTO the factory rather than being dropped, because both
are HTTP concerns rather than app knowledge:

- Range and If-None-Match are now forwarded for every sidecar. music needed both (seeking,
  and ETag revalidation returning a cheap 304 instead of a cover image) and slskd needed
  Range. Forwarding them everywhere costs nothing and removes the reason to hand-roll.
- timeoutSeconds, used only by music at 1800. A from-scratch reindex holds the proxied
  connection open for minutes with no bytes flowing, which the 60s idle timeout would drop.
  It applies to the whole prefix — the proxy must not know which of a sidecar's routes are
  slow.

The five side-effect imports in hono.ts are gone with them: the port listener now registers
when createSidecarProxy runs inside the router this file already imports. Vault keeps its
hand-rolled pair and its side-effect import — it is off-limits by standing instruction, and
is the one sidecar this commit deliberately does not touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:46:29 +00:00

116 lines
5.3 KiB
TypeScript

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;
/**
* 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;
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';
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 };
}