Files
gerbeur/vite.config.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

162 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react-swc";
import { lingui } from "@lingui/vite-plugin";
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { brotliCompressSync, constants as zlib, gzipSync } from "node:zlib";
import linguiConfig from "./lingui.config.ts";
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 EMAIL_ENABLED = process.env.GERBEUR_SMTPS_URL?.trim() ? "on" : "off";
// Cache-busting token for the favicon URLs (see index.html). Changes when the
// emoji changes, so browsers don't keep serving a stale cached icon. Mirrors
// Twemoji's filename convention (kept trivial here to avoid the frontend build
// depending on backend code; the canonical version lives in api/lib/site-icons.ts).
function emojiToCodepoint(emoji: string): string {
const cleaned = emoji.includes("‍") ? emoji : emoji.replace(/️/g, "");
return [...cleaned].map((ch) => ch.codePointAt(0)!.toString(16)).join("-");
}
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
// The emoji picker (frimousse) reads emojibase-data from ${url}/${locale}/{data,messages}.json.
// 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_URL = `/emojibase/${EMOJIBASE_VERSION}`;
const EMOJIBASE_FILES = linguiConfig.locales.flatMap((
locale,
) => [`${locale}/data.json`, `${locale}/messages.json`]);
function emojibase(): Plugin {
return {
name: "emojibase",
configureServer(server) {
server.middlewares.use(EMOJIBASE_URL, (req, res, next) => {
const file = req.url?.split("?")[0].slice(1) ?? "";
if (!EMOJIBASE_FILES.includes(file)) return next();
res.setHeader("Content-Type", "application/json");
res.end(readFileSync(join(EMOJIBASE_DIR, file)));
});
},
generateBundle() {
for (const file of EMOJIBASE_FILES) {
this.emitFile({
type: "asset",
fileName: `${EMOJIBASE_URL.slice(1)}/${file}`,
source: readFileSync(join(EMOJIBASE_DIR, file)),
});
}
},
};
}
// Writes .br and .gz next to every compressible file in the build, which Oak's
// send() serves in place of the original when the browser accepts them. The
// server itself compresses nothing. index.html is left out: the server fills in
// its placeholders at runtime (see api/lib/static.ts).
const COMPRESSIBLE = /\.(js|css|json|svg|webmanifest)$/;
function precompress(): Plugin {
let outDir = "";
return {
name: "precompress",
apply: "build",
configResolved(config) {
outDir = resolve(config.root, config.build.outDir);
},
closeBundle() {
const walk = (dir: string) => {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path);
else if (COMPRESSIBLE.test(name)) compress(path);
}
};
const compress = (path: string) => {
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,
},
});
const gz = gzipSync(raw, { level: 9 });
if (br.length < raw.length * 0.9) writeFileSync(`${path}.br`, br);
if (gz.length < raw.length * 0.9) writeFileSync(`${path}.gz`, gz);
};
walk(outDir);
},
};
}
// 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),
},
server: {
port: 3000,
watch: {
ignored: ["**/api/**"],
},
},
build: {
rollupOptions: {
output: {
// Only the framework, which every page needs and which changes least, gets
// a chunk of its own so it stays cached across deploys. Everything else is
// 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)
) {
return "vendor";
}
},
},
},
},
plugins: [
{
// In dev the API server isn't serving index.html, so replace the placeholders here.
// In production the Oak server does the replacement at runtime (see api/lib/static.ts).
name: "inject-site-name",
transformIndexHtml: {
order: "pre",
handler: (html, ctx) =>
ctx.server
? html
.replaceAll("__SITE_NAME__", SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
.replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE)
.replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED)
: html,
},
},
emojibase(),
precompress(),
lingui(),
react({
plugins: [["@lingui/swc-plugin", {}]],
}),
],
});