Files
av-club/sidecar/index.ts
T
pastilhasandClaude Opus 5 e2ed76eac4 bind a free port, do not assert one
a plugin sidecar does not choose a port. it binds whatever is free and REPORTS
it, which is how every other sidecar here works and why none of them can collide.
av-club asserted 3020 because it started life as a standalone service.

that assumption cost a deploy the same day it became a plugin: fofos still had a
copy of the pre-plugin code running from ~/garbage/av-club, on `main`, holding
3020 — so the plugin's sidecar died on EADDRINUSE in a restart loop while the
platform reported "sidecar not available". two things claiming one number, and
neither able to say so.

AV_CLUB_PORT still pins it, for running outside Officer — the standalone mode
fofos/platform was written against, where the other side has to know where to
look. inside Officer nothing sets it.

the announcement now sends `server.port`, never the requested value. reporting
what you asked for rather than what you got is how a sidecar ends up advertising
a port it is not on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:41:52 +01:00

242 lines
8.5 KiB
TypeScript

import { timingSafeEqual } from "node:crypto";
import { ASSETS_DIR, MAX_CONCURRENT_JOBS, MAX_UPLOAD_BYTES, PORT, requireToken } from "./config";
import { ApiError, invalid, notFound, toErrorResponse } from "./errors";
import { contentPath, deleteAsset, putAsset, readAsset } from "./assets/store";
import { ffprobe } from "./probe/ffprobe";
import { runSharp, type SharpRequest } from "./sharp/run";
import { runFfmpeg, type FfmpegRequest } from "./ffmpeg/run";
import sharp from "sharp";
import { createSidecarConnector } from "@@/sidecar/connect";
import { API_URL } from "@@/officer-url.mjs";
/**
* av-club — ffmpeg, sharp and ffprobe over HTTP.
*
* The security model is in docs/SECURITY.md and is not optional reading: the caller never names a
* path, and that single invariant is what makes it safe to expose the rest of these tools' surface.
*/
const encoder = new TextEncoder();
// Read once, at boot, so a missing token stops the process here rather than at the first request.
const TOKEN = requireToken();
/**
* Constant-time bearer check.
*
* `===` on secrets leaks their length and their common prefix through timing. The cost of doing it
* properly is three lines.
*/
function authorised(request: Request): boolean {
/*
The platform, having already authenticated somebody.
`createSidecarProxy` forwards `X-Officer-User` after the account gate and the `av-club` permission
gate have both passed, and this server binds LOOPBACK — so that header is only settable by something
already on this machine, which is the same trust boundary caldav's DAV door relies on. Requiring the
bearer token as well would mean shipping av-club's secret to every caller for no gain: the platform
is the thing deciding who may encode, and it has already decided.
*/
if (request.headers.get("x-officer-user")) return true;
const header = request.headers.get("authorization");
if (!header?.startsWith("Bearer ")) return false;
const provided = encoder.encode(header.slice(7));
const expected = encoder.encode(TOKEN);
if (provided.byteLength !== expected.byteLength) return false;
return timingSafeEqual(provided, expected);
}
/**
* Bound concurrent processing.
*
* sharp and ffmpeg will each take every core available. Without this, two large requests can make
* the box unresponsive to everything including the health check.
*/
let running = 0;
const queue: (() => void)[] = [];
async function withSlot<T>(work: () => Promise<T>): Promise<T> {
if (running >= MAX_CONCURRENT_JOBS) {
await new Promise<void>((resolve) => queue.push(resolve));
}
running++;
try {
return await work();
} finally {
running--;
queue.shift()?.();
}
}
/** Serve stored bytes, honouring Range — browsers will not scrub a <video> served without it. */
async function serveContent(id: string, request: Request): Promise<Response> {
const asset = await readAsset(id);
const file = Bun.file(contentPath(id));
const type =
asset.kind === "image"
? `image/${asset.format === "jpeg" ? "jpeg" : asset.format}`
: file.type || "application/octet-stream";
const range = request.headers.get("range");
const match = range?.match(/^bytes=(\d*)-(\d*)$/);
if (match) {
const size = asset.bytes;
const start = match[1] ? Number(match[1]) : 0;
const end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
if (!Number.isFinite(start) || start > end || start >= size) {
return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });
}
return new Response(file.slice(start, end + 1), {
status: 206,
headers: {
"Content-Type": type,
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": String(end - start + 1),
},
});
}
return new Response(file, {
headers: {
"Content-Type": type,
"Accept-Ranges": "bytes",
// Every id is unique and content never changes under one, so this is honest.
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
}
async function handleUpload(request: Request): Promise<Response> {
const form = await request.formData().catch(() => {
throw invalid("Expected a multipart/form-data body with a `file` field");
});
const file = form.get("file");
if (!(file instanceof File)) throw invalid("Expected a `file` field containing a file", "file");
if (file.size > MAX_UPLOAD_BYTES) {
throw new ApiError(
"payload_too_large",
`That file is ${(file.size / 1_048_576).toFixed(1)} MB. The limit is ${(MAX_UPLOAD_BYTES / 1_048_576).toFixed(0)} MB.`,
);
}
const asset = await withSlot(async () => putAsset(await file.arrayBuffer()));
return Response.json(asset, { status: 201 });
}
async function route(request: Request): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
if (path === "/health") {
return Response.json({ ok: true, service: "av-club", running, queued: queue.length });
}
if (!authorised(request)) {
return new ApiError("unauthorized", "Missing or invalid bearer token").toResponse();
}
if (path === "/v1/assets" && request.method === "POST") return handleUpload(request);
const asset = path.match(/^\/v1\/assets\/([^/]+)(\/content|\/probe)?$/);
if (asset) {
const [, id, suffix] = asset;
if (suffix === "/content" && request.method === "GET") return serveContent(id!, request);
if (suffix === "/probe" && request.method === "GET") {
const stored = await readAsset(id!);
if (stored.kind === "image") {
const metadata = await sharp(contentPath(id!)).metadata();
return Response.json({ kind: "image", metadata });
}
const probe = await withSlot(() => ffprobe(contentPath(id!)));
return Response.json({ kind: stored.kind, ...probe });
}
if (!suffix && request.method === "GET") return Response.json(await readAsset(id!));
if (!suffix && request.method === "DELETE") {
if (!(await readAsset(id!).then(() => true).catch(() => false))) throw notFound();
await deleteAsset(id!);
return new Response(null, { status: 204 });
}
}
if (path === "/v1/sharp" && request.method === "POST") {
const body = (await request.json().catch(() => {
throw invalid("Expected a JSON body");
})) as SharpRequest;
const result = await withSlot(() => runSharp(body));
return Response.json(result, { status: 201 });
}
if (path === "/v1/ffmpeg" && request.method === "POST") {
const body = (await request.json().catch(() => {
throw invalid("Expected a JSON body");
})) as FfmpegRequest;
const result = await withSlot(() => runFfmpeg(body));
return Response.json(result, { status: 201 });
}
throw notFound(`No route for ${request.method} ${path}`);
}
const server = Bun.serve({
port: PORT,
// Loopback, because `X-Officer-User` above is trusted and a header is only as good as who can set it.
hostname: "127.0.0.1",
// Bun's default is 10 seconds, which silently kills a long encode mid-flight.
idleTimeout: 255,
maxRequestBodySize: MAX_UPLOAD_BYTES,
fetch: (request) => route(request).catch(toErrorResponse),
});
/*
Register with the platform, and report the port it can be reached on.
The port is still `AV_CLUB_PORT` rather than an ephemeral one: this service is also usable directly —
by a script on the box, or by fofos/platform, which is its stated first consumer and is not Officer.
Being a plugin is an additional way in, not a replacement for the one it already had.
*/
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: "av-club",
handles: ["av-club"],
onCommand(_cmd, reply) {
reply({ type: "error", id: (_cmd as { id: string }).id, error: "av-club takes no commands; use its HTTP API" });
},
onConnected() {
connection.send({ type: "av-club:server", port: server.port! });
console.log(`[av-club] reported port ${server.port} to the API`);
},
});
function shutdown(signal: string) {
console.log(`[av-club] ${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"));
console.log(`av-club on ${server.url}`);
console.log(` assets ${ASSETS_DIR}`);
console.log(` max upload ${(MAX_UPLOAD_BYTES / 1_048_576).toFixed(0)} MB`);
console.log(` concurrency ${MAX_CONCURRENT_JOBS}`);