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>
This commit is contained in:
khannurien
2026-10-09 14:31:25 +00:00
parent c0d03e9aaa
commit a25500d833
9 changed files with 343 additions and 40 deletions

View File

@@ -1,3 +1,11 @@
FROM denoland/deno:bin-2.9.0 AS deno FROM denoland/deno:bin-2.9.0 AS deno
FROM mcr.microsoft.com/devcontainers/typescript-node:20 FROM mcr.microsoft.com/devcontainers/typescript-node:20
COPY --from=deno /deno /usr/local/bin/deno COPY --from=deno /deno /usr/local/bin/deno
# The API draws thumbnails and waveforms with ffmpeg, in the workspace and in
# the instances' services alike (compose.yaml), which don't get the features.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# The services share Deno's cache through a volume (compose.yaml), which Docker
# creates owned as this directory is, so it must exist and be node's.
RUN mkdir -p /home/node/.cache/deno && chown -R node:node /home/node/.cache

View File

@@ -0,0 +1,58 @@
# The dev environment: the workspace VS Code attaches to (`dev`), and one
# service per dev instance of gerbeur (dev/instances.ts), which start with it.
# Each instance service runs dev/start.ts for its own instance, which derives
# its ports, URLs, database and uploads from its entry there; a new instance is
# an entry there and a service here.
#
# Every service shares the host's network, as the workspace always did: an
# instance's URLs are localhost ones, which the browser and the other
# instances' servers must both reach.
#
# From the workspace's terminal (docker-outside-of-docker):
# docker compose -p gerbeur-dev logs -f musique
# docker compose -p gerbeur-dev restart gerbeur
# docker compose -p gerbeur-dev stop gerbeur musique # then `deno task dev`
name: gerbeur-dev
x-workspace: &workspace
build:
context: .
dockerfile: Dockerfile
image: gerbeur-dev
network_mode: host
user: node
working_dir: /workspaces/gerbeur
volumes:
- ..:/workspaces/gerbeur:cached
# Shared, so that each service doesn't download the jsr and npm packages
# again.
- deno-cache:/home/node/.cache/deno
x-instance: &instance
<<: *workspace
restart: unless-stopped
depends_on:
install:
condition: service_completed_successfully
services:
dev:
<<: *workspace
command: sleep infinity
# Installs the packages and draws the icons once, before any instance
# starts, so that the instances don't do it at the same time.
install:
<<: *workspace
command: sh -c "deno install && deno task icons public"
gerbeur:
<<: *instance
command: deno run --env-file -A dev/start.ts gerbeur
musique:
<<: *instance
command: deno run --env-file -A dev/start.ts musique
volumes:
deno-cache:

View File

@@ -1,10 +1,5 @@
{ {
"features": { "features": {
"ghcr.io/devcontainers-extra/features/ffmpeg-apt-get:1": {
"version": "1.0.17",
"resolved": "ghcr.io/devcontainers-extra/features/ffmpeg-apt-get@sha256:735491c8e9b2236cae727d6d4904663f6a58eb131d8c4b6b18fd0eea3d8c5b71",
"integrity": "sha256:735491c8e9b2236cae727d6d4904663f6a58eb131d8c4b6b18fd0eea3d8c5b71"
},
"ghcr.io/khannurien/devcontainer-features/claude-code:1": { "ghcr.io/khannurien/devcontainer-features/claude-code:1": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "ghcr.io/khannurien/devcontainer-features/claude-code@sha256:17eb69bfd15ff5206c0bff2945935a4349f7423cabee461a0ba2681d52205db9", "resolved": "ghcr.io/khannurien/devcontainer-features/claude-code@sha256:17eb69bfd15ff5206c0bff2945935a4349f7423cabee461a0ba2681d52205db9",

View File

@@ -1,19 +1,12 @@
{ {
"name": "Deno", "name": "gerbeur",
"build": {
"dockerfile": "Dockerfile"
},
// Features to add to the dev container. More info: https://containers.dev/features. // The workspace and the dev instances of gerbeur, one service each: see
// "features": {}, // compose.yaml. The instances start with the container.
"dockerComposeFile": "compose.yaml",
// Use 'forwardPorts' to make a list of ports inside the container available locally. "service": "dev",
// "forwardPorts": [], "workspaceFolder": "/workspaces/gerbeur",
"shutdownAction": "stopCompose",
"runArgs": ["--network=host"],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties. // Configure tool-specific properties.
"customizations": { "customizations": {
@@ -26,9 +19,8 @@
}, },
"features": { "features": {
"ghcr.io/warrenbuckley/codespace-features/sqlite:1": {}, "ghcr.io/warrenbuckley/codespace-features/sqlite:1": {},
"ghcr.io/devcontainers-extra/features/ffmpeg-apt-get:1": {} // The docker CLI, on the host's daemon, for the instances' logs and
// restarts (compose.yaml).
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
} }
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
} }

View File

@@ -31,15 +31,32 @@ cp .env.example .env
### Run ### Run
In the devcontainer, the dev instances listed in
[`dev/instances.ts`](dev/instances.ts) start with the container, each a service
of [`.devcontainer/compose.yaml`](.devcontainer/compose.yaml): `gerbeur` at
[http://localhost:3000](http://localhost:3000), `musique` at
[http://localhost:3001](http://localhost:3001). They run side by side so that
signing in from one instance to another can be tried on one machine, and from
the workspace's terminal:
```sh ```sh
deno task dev docker compose -p gerbeur-dev logs -f musique # follow an instance
docker compose -p gerbeur-dev restart gerbeur # restart one
``` ```
This starts both the API server (port 8000, with file watching) and the Vite dev Outside the devcontainer, or with those services stopped, `deno task dev` starts
server (port 3000) concurrently. them all, and `deno task dev gerbeur` only those named.
Open [http://localhost:3000](http://localhost:3000). On first run a default Each instance has an API server, restarted on change, and a Vite server, and its
`admin` / `admin` account is created — change the password immediately. output is prefixed with its name. They follow one rule
([`dev/start.ts`](dev/start.ts)): instance `<name>` keeps its database in
`api/sql/<name>.db` and its uploads in `api/uploads/<name>/`, reads `.env`, and
takes what sets it apart (ports, site name, emoji, style) from its entry. A new
instance is an entry in `dev/instances.ts` and a service in `compose.yaml`.
On first run each instance creates a default `admin` / `admin` account — change
the password immediately. To let one instance's users sign in to another, trust
it on the other's `/manage`.
### Environment variables ### Environment variables
@@ -141,13 +158,14 @@ can reach each other. An admin decides who comes in, on `/manage`:
- users of **any other** instance join with an invite, from the register page, - users of **any other** instance join with an invite, from the register page,
and sign in without one afterwards. and sign in without one afterwards.
To try it on one machine, run two instances on different ports with `deno task dev` runs several instances on one machine (see [Run](#run)), with
`GERBEUR_FEDERATION_ALLOW_PRIVATE=1`, which lets them reach each other over `GERBEUR_FEDERATION_ALLOW_PRIVATE=1`, which lets them reach each other over
plain http on localhost. Never set it in production. plain http on localhost. Never set it in production.
## Project structure ## Project structure
``` ```
dev/ # The dev instances (instances.ts) and their launcher (start.ts), for `deno task dev`
shared/ # Imported by both halves: API and socket types, limits, permissions shared/ # Imported by both halves: API and socket types, limits, permissions
test/ # Tests of shared/ (Deno): test/<module>.test.ts test/ # Tests of shared/ (Deno): test/<module>.test.ts
locales/ # Lingui catalogues (en, fr): the interface's and the emails' messages locales/ # Lingui catalogues (en, fr): the interface's and the emails' messages
@@ -171,8 +189,8 @@ api/
migrations/ # Versioned schema migrations migrations/ # Versioned schema migrations
test/ # Backend tests (Deno): test/<layer>/<module>.test.ts test/ # Backend tests (Deno): test/<layer>/<module>.test.ts
sql/ sql/
gerbeur.db # SQLite database (not committed) gerbeur.db # SQLite database (not committed); <name>.db per dev instance
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed) uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed); <name>/ per dev instance
src/ # React frontend (Vite) src/ # React frontend (Vite)
config/ # API base URL, default feed tab, player mode, instance settings config/ # API base URL, default feed tab, player mode, instance settings
pages/ # One component per route, nothing else pages/ # One component per route, nothing else

View File

@@ -1,6 +1,6 @@
{ {
"tasks": { "tasks": {
"dev": "deno task icons public && deno run --env-file -A npm:vite & deno task server:start", "dev": "deno task icons public && deno run --env-file -A dev/start.ts",
"build": "deno task i18n:extract && deno task i18n:compile && deno run --env-file -A npm:vite build", "build": "deno task i18n:extract && deno task i18n:compile && deno run --env-file -A npm:vite build",
"server:start": "deno run --env-file -A --watch api/main.ts", "server:start": "deno run --env-file -A --watch api/main.ts",
"icons": "deno run --env-file -A api/scripts/generate-icons.ts", "icons": "deno run --env-file -A api/scripts/generate-icons.ts",
@@ -12,8 +12,8 @@
"test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/", "test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/",
"test:web": "deno run -A npm:vitest run", "test:web": "deno run -A npm:vitest run",
"test:web:coverage": "deno run -A npm:vitest run --coverage", "test:web:coverage": "deno run -A npm:vitest run --coverage",
"doc": "deno doc --html --name=gerbeur --output=docs mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*')", "doc": "deno doc --html --name=gerbeur --output=docs mod.ts $(find api shared src dev \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*')",
"doc:lint": "deno run --allow-run --allow-read --allow-env doc-lint.ts mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*')" "doc:lint": "deno run --allow-run --allow-read --allow-env doc-lint.ts mod.ts $(find api shared src dev \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*')"
}, },
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto", "nodeModulesDir": "auto",

58
dev/instances.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Lists the dev instances of gerbeur, each an API server and a Vite server of
* its own, run side by side so that federated sign-in between instances can be
* tried on one machine. In the devcontainer each is a service of
* `.devcontainer/compose.yaml`, which starts with it; outside, `deno task dev`
* starts them all, or `deno task dev <name>…` those named. Both run
* `dev/start.ts`. A new instance is an entry here, and a service there.
*
* Every instance follows the same rule, which `start.ts` applies:
*
* - its API at `http://localhost:<apiPort>`, its pages at
* `http://localhost:<webPort>`;
* - its database at `api/sql/<name>.db`, its uploads under
* `api/uploads/<name>/`;
* - the base `.env` for everything else, `env` overriding it.
*
* A new database starts with the user `admin` / `admin`. The instances may
* reach each other over http on localhost; whether one trusts another is up to
* its admin, on `/manage`.
*
* @module
*/
/** One instance of {@link INSTANCES}. */
export interface DevInstance {
/**
* Names its database and uploads, and prefixes its output. Lowercase
* letters, digits and hyphens.
*/
name: string;
/** The port of its API server, `GERBEUR_PORT`. */
apiPort: number;
/** The port of its Vite server, where its pages are. */
webPort: number;
/**
* `GERBEUR_*` settings of its own, over the base `.env`: a site name, an
* emoji or a style, to tell the instances apart.
*/
env?: Record<string, string>;
}
/**
* The instances `deno task dev` starts, in this order. The first keeps the
* ports the single dev server always had.
*/
export const INSTANCES: readonly DevInstance[] = [
{ name: "gerbeur", apiPort: 8000, webPort: 3000 },
{
name: "musique",
apiPort: 8001,
webPort: 3001,
env: {
GERBEUR_SITE_NAME: "musique",
GERBEUR_SITE_EMOJI: "🎵",
GERBEUR_DEFAULT_STYLE: "kanagawa",
},
},
];

157
dev/start.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* 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);
}

View File

@@ -9,8 +9,12 @@ import { BASE_STYLE, isStyleName } from "./shared/theme.ts";
const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur"; const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur";
const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚"; const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚";
const BANDCAMP_PLAYER = process.env.GERBEUR_BANDCAMP_PLAYER?.trim() === "native" ? "native" : "embed"; const BANDCAMP_PLAYER = process.env.GERBEUR_BANDCAMP_PLAYER?.trim() === "native"
const DEFAULT_STYLE = isStyleName(process.env.GERBEUR_DEFAULT_STYLE?.trim()) ? process.env.GERBEUR_DEFAULT_STYLE!.trim() : BASE_STYLE; ? "native"
: "embed";
const DEFAULT_STYLE = isStyleName(process.env.GERBEUR_DEFAULT_STYLE?.trim())
? process.env.GERBEUR_DEFAULT_STYLE!.trim()
: BASE_STYLE;
const EMAIL_ENABLED = process.env.GERBEUR_SMTPS_URL?.trim() ? "on" : "off"; const EMAIL_ENABLED = process.env.GERBEUR_SMTPS_URL?.trim() ? "on" : "off";
// Cache-busting token for the favicon URLs (see index.html). Changes when the // Cache-busting token for the favicon URLs (see index.html). Changes when the
@@ -27,9 +31,12 @@ const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
// It is served from here rather than a CDN; the version is in the path so the // It is served from here rather than a CDN; the version is in the path so the
// files can be cached for good, and bumping the package moves the URL. // files can be cached for good, and bumping the package moves the URL.
const EMOJIBASE_DIR = resolve("node_modules/emojibase-data"); const EMOJIBASE_DIR = resolve("node_modules/emojibase-data");
const EMOJIBASE_VERSION = JSON.parse(readFileSync(join(EMOJIBASE_DIR, "package.json"), "utf8")).version; const EMOJIBASE_VERSION =
JSON.parse(readFileSync(join(EMOJIBASE_DIR, "package.json"), "utf8")).version;
const EMOJIBASE_URL = `/emojibase/${EMOJIBASE_VERSION}`; const EMOJIBASE_URL = `/emojibase/${EMOJIBASE_VERSION}`;
const EMOJIBASE_FILES = linguiConfig.locales.flatMap((locale) => [`${locale}/data.json`, `${locale}/messages.json`]); const EMOJIBASE_FILES = linguiConfig.locales.flatMap((
locale,
) => [`${locale}/data.json`, `${locale}/messages.json`]);
function emojibase(): Plugin { function emojibase(): Plugin {
return { return {
@@ -79,7 +86,10 @@ function precompress(): Plugin {
const raw = readFileSync(path); const raw = readFileSync(path);
if (raw.length < 1024) return; if (raw.length < 1024) return;
const br = brotliCompressSync(raw, { const br = brotliCompressSync(raw, {
params: { [zlib.BROTLI_PARAM_QUALITY]: 11, [zlib.BROTLI_PARAM_SIZE_HINT]: raw.length }, params: {
[zlib.BROTLI_PARAM_QUALITY]: 11,
[zlib.BROTLI_PARAM_SIZE_HINT]: raw.length,
},
}); });
const gz = gzipSync(raw, { level: 9 }); const gz = gzipSync(raw, { level: 9 });
if (br.length < raw.length * 0.9) writeFileSync(`${path}.br`, br); if (br.length < raw.length * 0.9) writeFileSync(`${path}.br`, br);
@@ -90,7 +100,12 @@ function precompress(): Plugin {
}; };
} }
// Each dev instance (dev/instances.ts) runs a Vite server of its own; sharing
// one dependency cache, two of them would rewrite it under each other.
const INSTANCE = process.env.GERBEUR_INSTANCE;
export default defineConfig({ export default defineConfig({
cacheDir: INSTANCE ? `node_modules/.vite/${INSTANCE}` : undefined,
define: { define: {
"import.meta.env.VITE_EMOJIBASE_URL": JSON.stringify(EMOJIBASE_URL), "import.meta.env.VITE_EMOJIBASE_URL": JSON.stringify(EMOJIBASE_URL),
}, },
@@ -108,7 +123,9 @@ export default defineConfig({
// left to the bundler, so libraries only the lazy chunks use (the emoji // left to the bundler, so libraries only the lazy chunks use (the emoji
// picker, forms) stay out of the first load. // picker, forms) stay out of the first load.
manualChunks(id) { manualChunks(id) {
if (/node_modules\/(react|react-dom|scheduler|react-router)\//.test(id)) { if (
/node_modules\/(react|react-dom|scheduler|react-router)\//.test(id)
) {
return "vendor"; return "vendor";
} }
}, },