Add officer-slskd, a singleton sidecar that reverse-proxies to a self-hosted slskd (Soulseek) instance and reports its loopback port to the API on connect. All slskd knowledge (URL + API key) lives in the sidecar; the platform is a thin auth+forward proxy for /api/slskd/* and holds no slskd credentials. Mirrors the officer-vault pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
6.0 KiB
TypeScript
142 lines
6.0 KiB
TypeScript
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<string, unknown>).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'));
|