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>
158 lines
4.7 KiB
TypeScript
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);
|
|
}
|