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>
This commit is contained in:
2026-07-31 11:46:29 +00:00
co-authored by Claude Opus 5
parent 7129cd82e6
commit aaf0161620
13 changed files with 100 additions and 362 deletions
+14 -47
View File
@@ -1,51 +1,18 @@
import { createRouter } from '../../create-router';
import { getSlskdServerUrl } from './sidecar-server';
import { createSidecarProxy } from '../../sidecar/create-proxy';
// Thin reverse-proxy for /api/slskd/*. The platform's ONLY job here is AUTH + FORWARDING: this router
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards
// the subpath + query + body to the officer-slskd sidecar, which OWNS the slskd contract and injects the
// slskd API key. The platform holds no slskd credentials.
// /api/slskd/* — auth, then forward to officer-slskd. No routes of its own and no slskd knowledge:
// this file must never grow app logic.
//
// This is a catch-all with no routes of its own: the full /api/slskd/* contract is documented at the top
// of the sidecar's fetch handler (src/servers/sidecar/slskd/index.ts). That contract covers both slskd's
// own API (/api/v0/searches, /api/v0/transfers, …) and the sidecar's Officer-owned routes (/_officer/*,
// features slskd has no concept of). Both are opaque from here — this file never grows Soulseek logic.
// Covers both slskd's own API (/api/v0/searches, /api/v0/transfers, …) and the sidecar's Officer-owned
// routes (/_officer/*, features slskd has no concept of). Both are opaque from here; the sidecar injects
// the slskd API key. The full contract is documented at the top of src/servers/sidecar/slskd/index.ts.
export const slskdRouter = createRouter();
const PREFIX = '/api/slskd';
slskdRouter.all('/*', async (ctx) => {
const baseUrl = getSlskdServerUrl();
if (!baseUrl) return ctx.text('slskd 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;
const range = ctx.req.header('range');
if (range) headers['Range'] = range;
// Forward the authenticated user id so the sidecar can serve its own Officer-owned routes (/_officer/*,
// e.g. favourite peers) against Postgres. The sidecar binds loopback only, so this header is trusted.
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) {
console.error('[slskd] proxy fetch failed', { target, error: String(err) });
return ctx.text('slskd sidecar unreachable', 502);
}
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
const proxy = createSidecarProxy({
name: 'slskd',
prefix: '/api/slskd',
});
export const slskdRouter = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getSlskdServerUrl = proxy.getHttpUrl;
-19
View File
@@ -1,19 +0,0 @@
import * as sidecar from '@@/sidecar-registry';
// The officer-slskd sidecar starts its slskd reverse-proxy on a random loopback port and reports it here
// on connect. We remember it so `/api/slskd/*` always forwards to the current sidecar. The platform holds
// NO knowledge of slskd itself (URL, API key) — only where the sidecar is listening.
let serverPort: number | null = null;
sidecar.on('slskd:server', (msg) => {
const port = (msg as { port?: number }).port;
if (typeof port !== 'number') return;
serverPort = port;
console.log(`[slskd] sidecar proxy registered on port ${port}`);
});
/** Base URL of the sidecar's HTTP proxy, or null if the sidecar hasn't reported in yet. */
export function getSlskdServerUrl(): string | null {
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
}