diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 2ab16ff6..af5ef9f5 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,3 +1,11 @@ FROM denoland/deno:bin-2.9.0 AS deno FROM mcr.microsoft.com/devcontainers/typescript-node:20 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 diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml new file mode 100644 index 00000000..e10d39d3 --- /dev/null +++ b/.devcontainer/compose.yaml @@ -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: diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 2b4e115e..92e462ef 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -1,10 +1,5 @@ { "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": { "version": "1.2.0", "resolved": "ghcr.io/khannurien/devcontainer-features/claude-code@sha256:17eb69bfd15ff5206c0bff2945935a4349f7423cabee461a0ba2681d52205db9", diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e8ae42da..aeddf786 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,19 +1,12 @@ { - "name": "Deno", - "build": { - "dockerfile": "Dockerfile" - }, + "name": "gerbeur", - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - "runArgs": ["--network=host"], - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "yarn install", + // The workspace and the dev instances of gerbeur, one service each: see + // compose.yaml. The instances start with the container. + "dockerComposeFile": "compose.yaml", + "service": "dev", + "workspaceFolder": "/workspaces/gerbeur", + "shutdownAction": "stopCompose", // Configure tool-specific properties. "customizations": { @@ -26,9 +19,8 @@ }, "features": { "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" } diff --git a/README.md b/README.md index 7bc283b4..edeb425f 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,32 @@ cp .env.example .env ### 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 -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 -server (port 3000) concurrently. +Outside the devcontainer, or with those services stopped, `deno task dev` starts +them all, and `deno task dev gerbeur` only those named. -Open [http://localhost:3000](http://localhost:3000). On first run a default -`admin` / `admin` account is created — change the password immediately. +Each instance has an API server, restarted on change, and a Vite server, and its +output is prefixed with its name. They follow one rule +([`dev/start.ts`](dev/start.ts)): instance `` keeps its database in +`api/sql/.db` and its uploads in `api/uploads//`, 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 @@ -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, 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 plain http on localhost. Never set it in production. ## 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 test/ # Tests of shared/ (Deno): test/.test.ts locales/ # Lingui catalogues (en, fr): the interface's and the emails' messages @@ -171,8 +189,8 @@ api/ migrations/ # Versioned schema migrations test/ # Backend tests (Deno): test//.test.ts sql/ - gerbeur.db # SQLite database (not committed) - uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed) + gerbeur.db # SQLite database (not committed); .db per dev instance + uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed); / per dev instance src/ # React frontend (Vite) config/ # API base URL, default feed tab, player mode, instance settings pages/ # One component per route, nothing else diff --git a/deno.json b/deno.json index a757ae17..536979ad 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "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", "server:start": "deno run --env-file -A --watch api/main.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:web": "deno run -A npm:vitest run", "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: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": "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 dev \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*')" }, "exports": "./mod.ts", "nodeModulesDir": "auto", diff --git a/dev/instances.ts b/dev/instances.ts new file mode 100644 index 00000000..6f8f81aa --- /dev/null +++ b/dev/instances.ts @@ -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 …` 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:`, its pages at + * `http://localhost:`; + * - its database at `api/sql/.db`, its uploads under + * `api/uploads//`; + * - 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; +} + +/** + * 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", + }, + }, +]; diff --git a/dev/start.ts b/dev/start.ts new file mode 100644 index 00000000..813d136c --- /dev/null +++ b/dev/start.ts @@ -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 { + 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, + out: typeof Deno.stdout, + prefix: string, +): Promise { + 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 { + 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); +} diff --git a/vite.config.ts b/vite.config.ts index de4ee968..4d1b399b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,8 +9,12 @@ import { BASE_STYLE, isStyleName } from "./shared/theme.ts"; const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur"; const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚"; -const BANDCAMP_PLAYER = process.env.GERBEUR_BANDCAMP_PLAYER?.trim() === "native" ? "native" : "embed"; -const DEFAULT_STYLE = isStyleName(process.env.GERBEUR_DEFAULT_STYLE?.trim()) ? process.env.GERBEUR_DEFAULT_STYLE!.trim() : BASE_STYLE; +const BANDCAMP_PLAYER = process.env.GERBEUR_BANDCAMP_PLAYER?.trim() === "native" + ? "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"; // 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 // files can be cached for good, and bumping the package moves the URL. 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_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 { return { @@ -79,7 +86,10 @@ function precompress(): Plugin { const raw = readFileSync(path); if (raw.length < 1024) return; 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 }); 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({ + cacheDir: INSTANCE ? `node_modules/.vite/${INSTANCE}` : undefined, define: { "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 // picker, forms) stay out of the first load. 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"; } },