import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import { createSidecarConnector } from '@@/sidecar/connect'; import { resetStaleSoulseekBrowses } from '../db'; import { getSlskdConfig, stripHopByHop } from './upstream'; import { handleConfigRoute, probe } from './config'; import { handleOfficerRoute } from './officer'; import { API_URL } from '@@/officer-url.mjs'; // The officer-slskd sidecar. Same philosophy as officer-vault / officer-music: a singleton process that // registers with the API server and OWNS a contract — here, a reverse-proxy to a self-hosted slskd // (Soulseek daemon). ALL knowledge of slskd (its URL, its API key) lives here; the platform API is a // thin, auth-gated forwarder that never sees the slskd credential. This lets the Soulseek client grow // end-to-end without the main server accreting any slskd-specific logic. // // ───────────────────────────────────────────────────────────────────────────────────────────────── // HTTP CONTRACT — the platform strips its /api/slskd mount prefix before forwarding, so requests arrive // here as slskd-root paths (e.g. /api/v0/searches, /api/v0/transfers, /api/v0/session). We inject // `X-API-Key` and pass method, path, query, headers, status and BOTH body streams through verbatim. // The server listens on a random loopback port, reported to the API on connect so it can route here. // // GET /_health ours (probes slskd's /health), not part of the slskd contract // GET /_config { configured, connection } — the URL and whether a key is stored, never the key // PUT /_config { url, apiKey? } — validated against the daemon, then stored encrypted. A blank // apiKey on an existing connection keeps the stored one // DEL /_config forget the connection // // Paths under `/_officer/` are also ours and are NOT forwarded: they're the features slskd has no concept // of (favourite peers, …), served straight from Postgres. See officer.ts for that contract. Keeping them // here rather than in the platform API is what lets the main server stay a pure proxy forever. // // Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind // loopback only, so its presence is the trust signal — a request without it did not come through the // platform, and the connection is per-owner data. // ───────────────────────────────────────────────────────────────────────────────────────────────── // // TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at // /hub/*. Add a WebSocket upgrade path here mirroring the officer-vault notifications proxy // (src/servers/sidecar/vault/index.ts) once the client needs real-time updates. SignalR carries its // credential as an `?access_token=` query param on the socket, so the key injection differs from HTTP. /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probeServer.port; probeServer.stop(true); if (p == null) throw new Error('failed to acquire a free port'); return p; } const port = getFreePort(); const server = Bun.serve({ port, hostname: '127.0.0.1', idleTimeout: 255, // browse/search can idle; Bun caps this at 255s maxRequestBodySize: 1024 * 1024 * 1024, // room for uploads / large browse responses async fetch(req) { const url = new URL(req.url); const officerUser = req.headers.get('X-Officer-User'); const userId = Number(officerUser); if (!officerUser || !Number.isInteger(userId) || userId <= 0) { return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); } if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) { try { return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length)); } catch (err) { console.error(`[slskd] ${req.method} ${url.pathname} failed`, err); return Response.json({ error: 'internal error' }, { status: 500 }); } } const cfg = await getSlskdConfig(userId); // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a // configured-but-broken daemon is the whole reason the flag is on the response. if (url.pathname === '/_health') { if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); const started = Date.now(); const result = await probe(cfg); const ms = Date.now() - started; if (!result.ok) return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 }); return Response.json({ ok: true, configured: true, version: result.version, server: result.server, ms }); } // Officer-owned routes — answered locally, never proxied (so they work even with slskd down). if (url.pathname.startsWith('/_officer/')) { try { const res = await handleOfficerRoute(req, url); if (res) return res; return new Response('not found', { status: 404 }); } catch (err) { console.error(`[slskd] ${req.method} ${url.pathname} failed`, err); return Response.json({ error: 'internal error' }, { status: 500 }); } } if (!cfg) return Response.json({ error: 'slskd not connected', configured: false }, { status: 503 }); const target = `${cfg.base}${url.pathname}${url.search}`; const method = req.method; const hasBody = method !== 'GET' && method !== 'HEAD'; const started = Date.now(); const headers = stripHopByHop(req.headers); if (cfg.apiKey) headers.set('X-API-Key', cfg.apiKey); // Bun/undici require half-duplex to stream a request body straight through. const init: RequestInit & { duplex?: 'half' } = { method, headers, body: hasBody ? req.body : undefined, redirect: 'manual', // a transparent proxy passes 3xx through rather than following them }; if (hasBody) init.duplex = 'half'; let upstream: Response; try { upstream = await fetch(target, init); } catch { console.error(`[slskd] ${method} ${url.pathname} -> upstream unreachable`); return new Response('slskd upstream unreachable', { status: 502 }); } console.log(`[slskd] ${method} ${url.pathname} -> ${upstream.status} ${Date.now() - started}ms`); return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); }, }); // No upstream in the banner: where slskd lives is now per-owner state read from the database per request, // not a constant this process knows at boot. console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port}`); // A background share-tree fetch dies with this process, so anything left 'pending' from the previous life // would spin in the UI forever. Clear it once, at boot, before serving. resetStaleSoulseekBrowses() .then((n) => n && console.log(`[slskd] failed ${n} browse snapshot(s) interrupted by restart`)) .catch((err) => console.error('[slskd] could not reset stale browse snapshots', err)); // ── Command handlers ── type ReplyFn = (msg: SidecarEvent) => void; function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } // ── Connect to API server ── const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'slskd', handles: ['slskd'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { // Tell the API where our proxy is listening, so it can forward /api/slskd/* here. connection.send({ type: 'slskd:server', port }); console.log(`[slskd] reported proxy port ${port} to API`); }, }); // ── Graceful shutdown ── function shutdown(signal: string) { console.log(`[slskd] ${signal} received, shutting down...`); try { server.stop(true); } catch { /* already stopped */ } connection.destroy(); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));