import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { getSlskdBase, getSlskdApiKey, stripHopByHop } from './upstream'; // 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. // `GET /_health` is ours (probes slskd's /health), not part of the slskd contract. The server listens on // a random loopback port, reported to the API on connect so it can route here. // ───────────────────────────────────────────────────────────────────────────────────────────────── // // 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. const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probe.port; probe.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 base = getSlskdBase(); // Reachability probe — ours, not part of the slskd contract. if (url.pathname === '/_health') { if (!base) return Response.json({ ok: false, error: 'SLSKD_URL not configured' }, { status: 503 }); const started = Date.now(); try { const r = await fetch(`${base}/health`, { method: 'GET', signal: AbortSignal.timeout(5000) }); return Response.json({ ok: r.ok, upstreamStatus: r.status, ms: Date.now() - started }); } catch { return Response.json({ ok: false, error: 'upstream unreachable', ms: Date.now() - started }, { status: 502 }); } } if (!base) return new Response('slskd upstream not configured', { status: 503 }); const target = `${base}${url.pathname}${url.search}`; const method = req.method; const hasBody = method !== 'GET' && method !== 'HEAD'; const started = Date.now(); const headers = stripHopByHop(req.headers); const apiKey = getSlskdApiKey(); if (apiKey) headers.set('X-API-Key', 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) }); }, }); console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port} -> ${getSlskdBase() ?? '(SLSKD_URL unset)'}`); // ── 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', capabilities: ['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'));