diff --git a/src/servers/api/cliamp/relay.ts b/src/servers/api/cliamp/relay.ts index 7a4fb1fd..a34d592d 100644 --- a/src/servers/api/cliamp/relay.ts +++ b/src/servers/api/cliamp/relay.ts @@ -1,5 +1,5 @@ import type { ServerWebSocket } from 'bun'; -import { getMusicServerWsUrl } from '../music/sidecar-server'; +import { getMusicServerWsUrl } from '../music/router'; // Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a // `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the diff --git a/src/servers/api/headscale/router.ts b/src/servers/api/headscale/router.ts index 09e30623..174735a3 100644 --- a/src/servers/api/headscale/router.ts +++ b/src/servers/api/headscale/router.ts @@ -1,50 +1,18 @@ -import { createRouter } from '../../create-router'; -import { getHeadscaleServerUrl } from './sidecar-server'; +import { createSidecarProxy } from '../../sidecar/create-proxy'; -// Thin reverse-proxy for /api/headscale/*. 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-headscale sidecar, which OWNS the Headscale contract and holds the -// admin API key. +// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge: +// this file must never grow app logic. // -// A catch-all with no routes of its own. Unlike /api/slskd this proxies nothing of the upstream's own -// surface — the sidecar exposes only Officer-owned routes under /_officer/, because Headscale's REST shape -// differs across releases and version handling belongs in the sidecar, not the browser. The full contract -// is documented at the top of src/servers/sidecar/headscale/index.ts. It is opaque from here: this file -// must never grow Headscale logic. +// The sidecar exposes only Officer-owned routes under `/_officer/` — Headscale's REST shape differs +// across releases, and version handling belongs in the sidecar. It holds the admin API key; the platform +// does not know Headscale's URL. -export const headscaleRouter = createRouter(); - -const PREFIX = '/api/headscale'; - -headscaleRouter.all('/*', async (ctx) => { - const baseUrl = getHeadscaleServerUrl(); - if (!baseUrl) return ctx.text('headscale 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 = {}; - const contentType = ctx.req.header('content-type'); - if (contentType) headers['Content-Type'] = contentType; - // Forward the authenticated user id so the sidecar can serve its Officer-owned routes. 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('[headscale] proxy fetch failed', { target, error: String(err) }); - return ctx.text('headscale sidecar unreachable', 502); - } - - return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) }); +const proxy = createSidecarProxy({ + name: 'headscale', + prefix: '/api/headscale', }); + +export const headscaleRouter = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getHeadscaleServerUrl = proxy.getHttpUrl; diff --git a/src/servers/api/headscale/sidecar-server.ts b/src/servers/api/headscale/sidecar-server.ts deleted file mode 100644 index 8fd9258d..00000000 --- a/src/servers/api/headscale/sidecar-server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as sidecar from '@@/sidecar-registry'; - -// The officer-headscale sidecar starts its HTTP server on a random loopback port and reports it here on -// connect. We remember it so `/api/headscale/*` always forwards to the current sidecar. The platform holds -// NO knowledge of Headscale itself — not its URL, and emphatically not its admin API key. - -let serverPort: number | null = null; - -sidecar.on('headscale:server', (msg) => { - const port = (msg as { port?: number }).port; - if (typeof port !== 'number') return; - serverPort = port; - console.log(`[headscale] sidecar registered on port ${port}`); -}); - -/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */ -export function getHeadscaleServerUrl(): string | null { - return serverPort ? `http://127.0.0.1:${serverPort}` : null; -} diff --git a/src/servers/api/invoiceshelf/router.ts b/src/servers/api/invoiceshelf/router.ts index 237ded65..9074f5d3 100644 --- a/src/servers/api/invoiceshelf/router.ts +++ b/src/servers/api/invoiceshelf/router.ts @@ -1,54 +1,16 @@ -import { createRouter } from '../../create-router'; -import { getInvoiceshelfServerUrl } from './sidecar-server'; +import { createSidecarProxy } from '../../sidecar/create-proxy'; -// Thin reverse-proxy for /api/invoiceshelf/*. 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-invoiceshelf sidecar, which OWNS the InvoiceShelf contract and holds -// the API token. +// /api/invoiceshelf/* — auth, then forward to officer-invoiceshelf. No routes of its own and no invoiceshelf knowledge: +// this file must never grow app logic. // -// A catch-all with no routes of its own. The sidecar exposes only Officer-owned routes under /_officer/, -// against an allow-list of resources — the administrative half of InvoiceShelf's API is unreachable by -// design. The full contract is documented at the top of src/servers/sidecar/invoiceshelf/index.ts. It is -// opaque from here: this file must never grow InvoiceShelf logic. -// -// Note which headers are forwarded, and which are NOT. Origin and Referer are deliberately dropped: if either -// reaches InvoiceShelf, its statefulApi() middleware switches from token auth to session+CSRF and every -// request 419s. The browser sets them on same-origin XHR, so passing them through would break the sidecar in -// a way that looks like an auth bug. +// The sidecar owns the InvoiceShelf contract and holds its credentials. -export const invoiceshelfRouter = createRouter(); - -const PREFIX = '/api/invoiceshelf'; - -invoiceshelfRouter.all('/*', async (ctx) => { - const baseUrl = getInvoiceshelfServerUrl(); - if (!baseUrl) return ctx.text('invoiceshelf 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 = {}; - const contentType = ctx.req.header('content-type'); - if (contentType) headers['Content-Type'] = contentType; - // Forward the authenticated user id so the sidecar can serve its Officer-owned routes. 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('[invoiceshelf] proxy fetch failed', { target, error: String(err) }); - return ctx.text('invoiceshelf sidecar unreachable', 502); - } - - return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) }); +const proxy = createSidecarProxy({ + name: 'invoiceshelf', + prefix: '/api/invoiceshelf', }); + +export const invoiceshelfRouter = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getInvoiceshelfServerUrl = proxy.getHttpUrl; diff --git a/src/servers/api/invoiceshelf/sidecar-server.ts b/src/servers/api/invoiceshelf/sidecar-server.ts deleted file mode 100644 index 9ec8fa4e..00000000 --- a/src/servers/api/invoiceshelf/sidecar-server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as sidecar from '@@/sidecar-registry'; - -// The officer-invoiceshelf sidecar starts its HTTP server on a random loopback port and reports it here on -// connect. We remember it so `/api/invoiceshelf/*` always forwards to the current sidecar. The platform holds -// NO knowledge of InvoiceShelf itself — not its URL, not its API token, and not which company it acts as. - -let serverPort: number | null = null; - -sidecar.on('invoiceshelf:server', (msg) => { - const port = (msg as { port?: number }).port; - if (typeof port !== 'number') return; - serverPort = port; - console.log(`[invoiceshelf] sidecar registered on port ${port}`); -}); - -/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */ -export function getInvoiceshelfServerUrl(): string | null { - return serverPort ? `http://127.0.0.1:${serverPort}` : null; -} diff --git a/src/servers/api/music/router.ts b/src/servers/api/music/router.ts index fac0dba8..5910a2a6 100644 --- a/src/servers/api/music/router.ts +++ b/src/servers/api/music/router.ts @@ -1,69 +1,22 @@ -import { createRouter } from '../../create-router'; -import { getMusicServerUrl } from './sidecar-server'; +import { createSidecarProxy } from '../../sidecar/create-proxy'; -// Thin reverse-proxy for /api/music/*. The platform's ONLY job here is AUTH + FORWARDING. userMiddleware -// (upstream — this router mounts under the protected /api tree, so the media `?token=` path also works) -// authenticates; we forward the subpath + query + body + Range to the officer-music sidecar, which OWNS -// the entire /api/music/* contract: streaming, indexing, AND per-user state (favorites, now-playing, -// playlists) backed by Postgres. We inject the authenticated user id as `X-Officer-User` so the sidecar -// can serve that per-user state — the sidecar is loopback-only, so it trusts the header. +// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge: +// this file must never grow app logic. // -// This is a catch-all with no routes of its own: the full /api/music/* HTTP contract (paths, methods, -// SSE/response shapes) is documented at the top of the sidecar's fetch handler — src/servers/sidecar/music/index.ts. +// The sidecar owns the entire /api/music/* contract: streaming, indexing, AND per-user state (favorites, +// now-playing, playlists) backed by Postgres. The full HTTP contract is documented at the top of the +// sidecar's fetch handler — src/servers/sidecar/music/index.ts. -export const musicRouter = createRouter(); - -const PREFIX = '/api/music'; - -musicRouter.all('/*', async (ctx) => { - const baseUrl = getMusicServerUrl(); - if (!baseUrl) return ctx.text('Music sidecar not available', 503); - - const url = new URL(ctx.req.url); - const subpath = url.pathname.slice(PREFIX.length) || '/'; - const target = `${baseUrl}${subpath}${url.search}`; - - // A from-scratch reindex holds this proxied connection open for minutes with no bytes flowing, which - // the main server's 60s idle timeout would drop. Extend every request under this prefix to 30 min (Bun - // passes the server as Hono's env) — the proxy must not know which of the sidecar's routes are slow, and - // the sidecar applies its own per-request extension anyway. - const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined; - try { - server?.timeout?.(ctx.req.raw, 1800); - } catch { - /* older Bun / no per-request timeout — the build still completes in the background */ - } - - const method = ctx.req.method; - const headers: Record = {}; - const range = ctx.req.header('range'); - if (range) headers['Range'] = range; - const contentType = ctx.req.header('content-type'); - if (contentType) headers['Content-Type'] = contentType; - // Forward conditional-request headers so the sidecar's ETag(=v) revalidation works: a `no-cache` - // artifact (cover/meta/…) gets a cheap 304 when unchanged, a fresh 200 when its version changed. - const inm = ctx.req.header('if-none-match'); - if (inm) headers['If-None-Match'] = inm; - // Forward the authenticated user id so the sidecar can serve its per-user state routes (favorites / - // now-playing / playlists). The sidecar binds loopback only, so this header is trusted. - headers['X-Officer-User'] = String(ctx.get('user').id); - - // Forward the request body for mutating methods (favorites/now-playing/playlist writes). Streaming + - // reindex are GET/bodyless POST, so this is a no-op there. - 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('[music] proxy fetch failed', { target, error: String(err) }); - return ctx.text('Music sidecar unreachable', 502); - } - - // Pass status + headers through and stream the body (206/Content-Range/X-Audio-Duration included). - return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) }); +const proxy = createSidecarProxy({ + name: 'music', + prefix: '/api/music', + timeoutSeconds: 1800, }); + +export const musicRouter = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getMusicServerUrl = proxy.getHttpUrl; + +/** The same server as a `ws://` base — the cliamp relay pipes the player socket to it. */ +export const getMusicServerWsUrl = proxy.getWsUrl; diff --git a/src/servers/api/music/sidecar-server.ts b/src/servers/api/music/sidecar-server.ts deleted file mode 100644 index 22cd23f6..00000000 --- a/src/servers/api/music/sidecar-server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as sidecar from '@@/sidecar-registry'; - -// The officer-music sidecar starts its audio-streaming HTTP server on a random port and reports it here -// on connect. We remember it so `/api/music/*` always proxies to the current server. - -let serverPort: number | null = null; - -sidecar.on('music:server', (msg) => { - const port = (msg as { port?: number }).port; - if (typeof port !== 'number') return; - serverPort = port; - console.log(`[music] sidecar audio server registered on port ${port}`); -}); - -/** Base URL of the sidecar's audio server, or null if the sidecar hasn't reported in yet. */ -export function getMusicServerUrl(): string | null { - return serverPort ? `http://127.0.0.1:${serverPort}` : null; -} - -/** Same server, ws:// scheme — for the cliamp sockets the platform relays (see api/cliamp/relay.ts). */ -export function getMusicServerWsUrl(): string | null { - return serverPort ? `ws://127.0.0.1:${serverPort}` : null; -} diff --git a/src/servers/api/slskd/router.ts b/src/servers/api/slskd/router.ts index 2a5efde1..f3c63d96 100644 --- a/src/servers/api/slskd/router.ts +++ b/src/servers/api/slskd/router.ts @@ -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 = {}; - 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; diff --git a/src/servers/api/slskd/sidecar-server.ts b/src/servers/api/slskd/sidecar-server.ts deleted file mode 100644 index bf8516f7..00000000 --- a/src/servers/api/slskd/sidecar-server.ts +++ /dev/null @@ -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; -} diff --git a/src/servers/api/transmission/router.ts b/src/servers/api/transmission/router.ts index d5f54653..345e1cec 100644 --- a/src/servers/api/transmission/router.ts +++ b/src/servers/api/transmission/router.ts @@ -1,50 +1,17 @@ -import { createRouter } from '../../create-router'; -import { getTransmissionServerUrl } from './sidecar-server'; +import { createSidecarProxy } from '../../sidecar/create-proxy'; -// Thin reverse-proxy for /api/transmission/*. 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-transmission sidecar, which OWNS the Transmission contract and holds -// the daemon credentials. +// /api/transmission/* — auth, then forward to officer-transmission. No routes of its own and no transmission knowledge: +// this file must never grow app logic. // -// A catch-all with no routes of its own. The sidecar exposes only Officer-owned routes under /_officer/, -// because Transmission's RPC is a single POST endpoint guarded by a rotating CSRF token — proxying it raw -// would push the handshake into the browser. The full contract is documented at the top of -// src/servers/sidecar/transmission/index.ts. It is opaque from here: this file must never grow Transmission -// logic. +// The sidecar owns the Transmission RPC contract, including its session-id handshake, and holds the +// credentials. The platform knows none of it. -export const transmissionRouter = createRouter(); - -const PREFIX = '/api/transmission'; - -transmissionRouter.all('/*', async (ctx) => { - const baseUrl = getTransmissionServerUrl(); - if (!baseUrl) return ctx.text('transmission 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 = {}; - const contentType = ctx.req.header('content-type'); - if (contentType) headers['Content-Type'] = contentType; - // Forward the authenticated user id so the sidecar can serve its Officer-owned routes. 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('[transmission] proxy fetch failed', { target, error: String(err) }); - return ctx.text('transmission sidecar unreachable', 502); - } - - return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) }); +const proxy = createSidecarProxy({ + name: 'transmission', + prefix: '/api/transmission', }); + +export const transmissionRouter = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getTransmissionServerUrl = proxy.getHttpUrl; diff --git a/src/servers/api/transmission/sidecar-server.ts b/src/servers/api/transmission/sidecar-server.ts deleted file mode 100644 index ec442925..00000000 --- a/src/servers/api/transmission/sidecar-server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as sidecar from '@@/sidecar-registry'; - -// The officer-transmission sidecar starts its HTTP server on a random loopback port and reports it here on -// connect. We remember it so `/api/transmission/*` always forwards to the current sidecar. The platform -// holds NO knowledge of Transmission itself — not its URL, and not its credentials. - -let serverPort: number | null = null; - -sidecar.on('transmission:server', (msg) => { - const port = (msg as { port?: number }).port; - if (typeof port !== 'number') return; - serverPort = port; - console.log(`[transmission] sidecar registered on port ${port}`); -}); - -/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */ -export function getTransmissionServerUrl(): string | null { - return serverPort ? `http://127.0.0.1:${serverPort}` : null; -} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index de2d3c6f..0b121fa4 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -30,12 +30,9 @@ import { vpnRouter } from './api/vpn/router'; import { terminalRouter } from './api/terminal/sidecar-server'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { activityRouter } from './api/activity/router'; -import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server port +// Vault still hand-rolls its port capture, so it keeps a side-effect import; every other HTTP sidecar +// registers its listener when createSidecarProxy runs inside the router this file already imports. import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port -import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy port -import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port -import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port -import './api/invoiceshelf/sidecar-server'; // side-effect: capture the officer-invoiceshelf server port import { dockRouter } from './api/dock/dock'; import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations'; import { queueRouter } from './api/queue/queue'; diff --git a/src/servers/sidecar/create-proxy.ts b/src/servers/sidecar/create-proxy.ts index fabb2c24..87447dec 100644 --- a/src/servers/sidecar/create-proxy.ts +++ b/src/servers/sidecar/create-proxy.ts @@ -27,6 +27,13 @@ export type SidecarProxyParams = { 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 = { @@ -38,7 +45,7 @@ export type SidecarProxy = { getWsUrl: () => string | null; }; -export function createSidecarProxy({ name, prefix, onRegister }: SidecarProxyParams): SidecarProxy { +export function createSidecarProxy({ name, prefix, onRegister, timeoutSeconds }: SidecarProxyParams): SidecarProxy { let serverPort: number | null = null; sidecar.on(`${name}:server`, (msg) => { @@ -62,10 +69,26 @@ export function createSidecarProxy({ name, prefix, onRegister }: SidecarProxyPar 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 = {}; - const contentType = ctx.req.header('content-type'); - if (contentType) headers['Content-Type'] = contentType; + // 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);