Files
gerbeur/dev/start.ts
khannurien a25500d833 v4: the devcontainer starts several gerbeur instances side by side, so that signing in from one to another can be tried on one machine — the dev environment ran a single instance. dev/instances.ts lists them (gerbeur on :3000 and :8000, musique on :3001 and :8001), and dev/start.ts starts an API server, restarted on change, and a Vite server for each instance it's given, by one rule: instance <name> keeps api/sql/<name>.db and api/uploads/<name>/, reads .env, and takes its ports and what sets it apart from its entry, with federation allowed over http on localhost. It prefixes each line of output with the instance's name, and stops every server when one stops, such as a Vite server whose port is taken. The devcontainer is now a compose project (.devcontainer/compose.yaml): the workspace, a one-shot install that runs deno install and draws the icons once, and a service per instance running dev/start.ts for it, all on the host's network as before and sharing Deno's cache through a volume. ffmpeg moves from a feature into the Dockerfile, since the instances' services get no features, and docker-outside-of-docker gives the workspace's terminal their logs and restarts. Outside the devcontainer, deno task dev starts them all, or those named. Each Vite server keeps its own dependency cache. The existing dev uploads move to api/uploads/gerbeur/.
dev/start.ts was checked by hand: musique starting alone on a fresh database, an unknown name refused, a taken port stopping both servers, and Ctrl+C or SIGTERM leaving no server behind. The compose project couldn't be run here (no Docker in the workspace): its YAML parses and resolves as intended, but it is untested; tsc, eslint, deno check, deno fmt, deno lint and doc:lint, which now covers dev/, are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:31:25 +00:00

158 lines
4.7 KiB
TypeScript

/**
* Starts the dev instances of `dev/instances.ts`, for `deno task dev`: for
* each, an API server that restarts on change and a Vite server, wired to
* each other and kept apart from the other instances by the rule that module
* states. Every line they print is prefixed with the instance's name. Ctrl+C
* stops them all, and so does any of them stopping, such as a Vite server
* whose port is taken.
*
* It runs with the base `.env` loaded, which every server inherits, and starts
* the instances it is given by name, or all of them. In the devcontainer, each
* instance's service in `.devcontainer/compose.yaml` runs it for that
* instance; outside, `deno task dev` runs it.
*
* @module
*/
import { type DevInstance, INSTANCES } from "./instances.ts";
/** The colors of the instances' prefixes, in the order of `INSTANCES`. */
const COLORS = [36, 35, 33, 32, 34, 31];
/**
* Returns the environment of `instance`'s servers, over the inherited one:
* the rule of `dev/instances.ts`, then the instance's own `env`.
*/
function envOf(instance: DevInstance): Record<string, string> {
const { name, apiPort, webPort } = instance;
return {
GERBEUR_INSTANCE: name,
GERBEUR_PORT: String(apiPort),
GERBEUR_PUBLIC_URL: `http://localhost:${apiPort}`,
GERBEUR_FRONTEND_URL: `http://localhost:${webPort}`,
GERBEUR_DB_PATH: `api/sql/${name}.db`,
GERBEUR_UPLOADS_DIR: `api/uploads/${name}`,
// Every instance is on localhost, over http.
GERBEUR_FEDERATION_ALLOW_PRIVATE: "1",
VITE_API_PROTOCOL: "http",
VITE_API_HOSTNAME: "localhost",
VITE_API_PORT: String(apiPort),
...instance.env,
};
}
/** Writes each line of `stream` to `out`, after `prefix`. */
async function pipeLines(
stream: ReadableStream<Uint8Array>,
out: typeof Deno.stdout,
prefix: string,
): Promise<void> {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
let rest = "";
for await (const bytes of stream) {
const lines = (rest + decoder.decode(bytes, { stream: true })).split("\n");
rest = lines.pop() ?? "";
for (const line of lines) {
await out.write(encoder.encode(`${prefix} ${line}\n`));
}
}
if (rest) await out.write(encoder.encode(`${prefix} ${rest}\n`));
}
/** Returns the instances `names` asks for, all of them when it is empty. */
function pick(names: string[]): readonly DevInstance[] {
const unknown = names.filter((n) => !INSTANCES.some((i) => i.name === n));
if (unknown.length > 0) {
console.error(
`Unknown instance ${unknown.join(", ")}; dev/instances.ts has ${
INSTANCES.map((i) => i.name).join(", ")
}.`,
);
Deno.exit(1);
}
return names.length === 0
? INSTANCES
: INSTANCES.filter((i) => names.includes(i.name));
}
const chosen = pick(Deno.args);
const width = Math.max(...chosen.map((i) => i.name.length)) + 4;
const children: { label: string; process: Deno.ChildProcess }[] = [];
for (const instance of chosen) {
const color = COLORS[INSTANCES.indexOf(instance) % COLORS.length];
const env = envOf(instance);
for (
const [role, args] of [
["api", ["run", "-A", "--watch", "api/main.ts"]],
[
"web",
[
"run",
"-A",
"npm:vite",
"--port",
String(instance.webPort),
"--strictPort",
],
],
] as const
) {
const label = `${instance.name}:${role}`;
const prefix = `\x1b[${color}m${label.padEnd(width)}\x1b[0m│`;
const process = new Deno.Command(Deno.execPath(), {
args: [...args],
env,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).spawn();
pipeLines(process.stdout, Deno.stdout, prefix);
pipeLines(process.stderr, Deno.stderr, prefix);
children.push({ label, process });
}
}
console.log(
chosen.map((i) =>
`${
i.name.padEnd(width)
} http://localhost:${i.webPort} (API :${i.apiPort}, api/sql/${i.name}.db)`
).join("\n"),
);
let stopping = false;
/** Stops every server still running, then exits with `code`. */
async function stopAll(code: number): Promise<never> {
if (!stopping) {
stopping = true;
for (const { process } of children) {
try {
process.kill("SIGTERM");
} catch {
// Already gone.
}
}
await Promise.allSettled(children.map((c) => c.process.status));
}
Deno.exit(code);
}
for (const signal of ["SIGINT", "SIGTERM"] as const) {
Deno.addSignalListener(signal, () => stopAll(0));
}
const first = await Promise.race(
children.map(async ({ label, process }) => ({
label,
status: await process.status,
})),
);
if (!stopping) {
console.error(
`${first.label} stopped (exit ${first.status.code}); stopping the others.`,
);
await stopAll(first.status.code || 1);
}