import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { defineConfig, type Plugin } from "vite"; /** * The office's copy of the page, emitted from the city's. * * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind * two names, and the app works out which door it is at runtime by reading its * own hostname. A crawler cannot: it reads the HTML and nothing else. So a * single `index.html` means both doors unfurl with the same title, the same * sentence and the same picture — and the office door's whole reason to exist is * that it is a different place. * * The obvious fix, a second hand-written `office.html`, is the one the Caddy * config already argues against for the static root: "sharing the root rather * than copying it means a deploy cannot leave the two doors on different * builds". Two 900-line files each carrying the whole inline stylesheet would * drift on the first CSS change, and nothing would notice, because the drift is * invisible until somebody opens the other door. * * So there is one source and the build emits both shells. Everything outside the * `ogc:` markers in `index.html` is copied byte for byte — the stylesheet, the * DOM, the script tag Vite has already rewritten to the hashed bundle — and only * the block between them is replaced. A change to the interface reaches both * doors by construction, and the only thing that can differ is the thing that is * supposed to. * * It runs in `writeBundle` rather than `transformIndexHtml` because it needs the * *finished* document, after Vite has substituted the asset URLs. Transforming * earlier would emit a page pointing at unhashed source paths. */ function twoDoors(): Plugin { const MARKER_END = ""; const OFFICE = ` Spaces — an office you can walk around `; return { name: "lumbridge:two-doors", apply: "build", async writeBundle(options) { const dir = options.dir ?? "dist"; const html = await readFile(join(dir, "index.html"), "utf8"); const open = html.indexOf("ogc:start"); const end = html.indexOf(MARKER_END); // Loudly, rather than by silently shipping two identical shells. A card // that is quietly the wrong one is the failure this plugin exists to // prevent, so losing the markers must not be a no-op. if (open === -1 || end === -1) { this.error("index.html has no ogc:start/ogc:end markers — cannot emit office.html"); return; } // The opening marker sits inside a larger comment, so cut from the start // of that comment: otherwise the explanation ships on both doors while // being true of only one. const from = html.lastIndexOf("" + OFFICE + html.slice(end + MARKER_END.length); await writeFile(join(dir, "office.html"), office, "utf8"); }, }; } /** * Cut `satellite.js`'s WASM runtime out of the bundle. * * `satellite.js`'s entry point ends with `export * from './wasm/index.js'`, and * that subtree is an Emscripten build: a megabyte of generated glue that reaches * for `node:module` and `node:worker_threads`, wraps itself in a top-level * `await`, and cannot be tree-shaken away because a star re-export of a module * with side effects is not something a bundler may drop on its own. * * Measured, before this existed: **308 kB** of minified WASM loader shipped to * every visitor, in a chunk nothing ever called. `engine/satellites.ts` uses the * pure-JS `propagate` and says in its own header why it does not want the * `BulkPropagator` — a binary loaded at runtime plus a fallback path for when * that fails, to buy back two milliseconds a frame that are already budgeted. * Paying 308 kB to *not* use it was the worst of both. * * The stub is empty because nothing imports a name from it. If a future version * of this repo does want the bulk propagator, the fix is to delete this plugin * and pay the kilobytes deliberately — not to widen the stub. * * Matching is on the **importer** as well as the specifier, so this cannot * silently swallow some other package's `./wasm/index.js`. */ function noWasmPropagator(): Plugin { const STUB = "\0lumbridge:satellite-wasm-stub"; return { name: "lumbridge:no-wasm-propagator", enforce: "pre", resolveId(source, importer) { if (source !== "./wasm/index.js") return null; if (importer === undefined || !importer.includes("satellite.js")) return null; return STUB; }, load(id) { return id === STUB ? "export {};" : null; }, }; } export default defineConfig({ // Mounted under tera.lumbridgecorp.com in production; the trailing // slash matters, since every asset URL is resolved against it. base: process.env.TERA_BASE ?? "/", plugins: [noWasmPropagator(), twoDoors()], /** * The heightfield worker is constructed as `{ type: "module" }` in * `world.ts`, and this is the build setting that agrees with it. * * Vite's default worker format is `iife`, which was survivable while nothing * in the graph needed anything an IIFE cannot express — and stopped being * survivable the moment it did. `satellite.js` reaches a WASM runtime with a * top-level `await` in it, and rollup's answer to a top-level await in an * `iife` chunk is to fail the whole build, with an error naming a file no * worker imports. * * So this is agreement rather than a workaround: the module the browser is * told to load as an ES module is now built as one. */ worker: { format: "es" }, build: { outDir: "dist", target: "es2022", // Two entries. `login.html` used to sit in `public/`, which Vite copies // verbatim — so `import.meta.env` was never substituted there and the page // could not be told which identity provider to sign in against. It is a real // entry now; the built URL (`/login.html`) is unchanged. // // `office.html` is deliberately not a third entry: it is the same document // as `index.html` with a different head, so making it one would give it its // own copy of the bundle graph. It is emitted after the build instead. rollupOptions: { input: { index: "index.html", login: "login.html" }, output: { /** * three.js and the SGP4 propagator get a chunk of their own. * * Not to make the download smaller — it is the same bytes either way — * but to stop them being *re-downloaded*. They were inside the entry * chunk, so every deploy that changed a line of app code invalidated * three quarters of a megabyte of dependency that had not changed since * the last release. Split out, a returning visitor pays for the app and * keeps the vendor chunk it already has. * * They are one chunk rather than two because they are always wanted * together: `engine/satellites.ts` imports both, and it is reached from * the entry on every board. * * Measured: the entry chunk goes from 758 kB to 208 kB and the vendor * chunk is 550 kB. Rollup's 500 kB warning therefore still fires — and * it should. It now points at three.js, where it is a true statement * about a dependency nobody here can shrink, instead of at our own code, * where it was pointing at three.js all along and reading as if it were * about us. Raising `chunkSizeWarningLimit` would have hidden both. */ manualChunks: { vendor: ["three", "satellite.js"], }, }, }, }, });