import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import tailwind from 'bun-plugin-tailwind'; import { PLATFORM_DIR } from '../data-path'; import { mountPrefix } from './manifest'; import type { PluginState } from './mount'; // Writing the file the bundler reads, and rebuilding the SPA. // // ── Why a generated file at all ── // // A bundler cannot follow `import(someRuntimeString)`. The specifier has to be concrete before the build // runs, so "which plugins have a frontend" cannot be answered from the database at render time — it has // to be written into source first. That is this file. // // `App.tsx` keeps its core routes and gains one map over `plugins`. Everything the shell used to hardcode // per plugin — the route pair, the screen barrel, the panel metas — collapses into what is generated here. // // ── What is NOT here ── // // The dock tile. It is permission-filtered, and permission is a runtime question: a grant takes effect on // the next request, not on the next build. So the tile is served by the self endpoint at request time, // and this file carries only what the BUNDLER needs. const GENERATED_PATH = join(PLATFORM_DIR, 'src/apps/officer-web/Plugins.gen.tsx'); const BUILD_DIR = join(PLATFORM_DIR, 'build'); /** Where a build lands before it is swapped into place. Never served. */ const STAGING_DIR = join(PLATFORM_DIR, 'build.next'); const HTML_ENTRY = join(PLATFORM_DIR, 'src/apps/officer-web/index.gen.html'); /** * The shell inside `BUILD_DIR`, named after the ENTRYPOINT rather than `index.html`. * * Bun names an HTML output for its entry file, and `naming: { entry: '[name].[ext]' }` does not change it * — `[name]` resolves to `index.gen`. Found by a 503 on the first boot after the switch. Exported so the * server reads one constant instead of restating a filename it would get wrong the same way. */ export const SHELL_FILE = 'index.gen.html'; /** Import specifier from the generated file's directory to a plugin file, POSIX-style for the bundler. */ function specifier(target: string): string { const rel = relative(dirname(GENERATED_PATH), target).split('\\').join('/'); return (rel.startsWith('.') ? rel : `./${rel}`).replace(/\.tsx?$/, ''); } const HEADER = `// GENERATED by servers/plugins/generate.ts. Do not edit, and do not commit — it describes what is // installed on THIS machine, and is rewritten on every install, uninstall, enable and disable. // // A bundler cannot follow a runtime string, so the imports below have to be concrete before the build // runs. That is the whole reason this file exists rather than App.tsx reading the database. `; /** * Write `Plugins.gen.tsx` for the plugins that are installed, enabled and have a frontend. * * Always written, even when empty — App.tsx imports it unconditionally, and a missing file is a build * error rather than an empty list. */ export function generatePluginsModule(states: PluginState[]): string { const withWeb = states.filter((s) => s.install?.enabled && s.plugin.web); const imports: string[] = []; const entries: string[] = []; withWeb.forEach((state, i) => { const { plugin } = state; const alias = `Plugin${i}`; imports.push(`import { appRegistryMetas as ${alias}Panels } from '${specifier(plugin.web!.panels)}';`); imports.push(`import { defaultLayout as ${alias}Layout } from '${specifier(plugin.web!.layout)}';`); entries.push( ` { appName: '${plugin.appName}', route: '${mountPrefix(plugin)}', panels: ${alias}Panels, layout: ${alias}Layout },`, ); }); const body = `${HEADER} import type { AppRegistryMeta, LayoutNode } from 'officerdev'; ${imports.join('\n')} export type GeneratedPlugin = { appName: string; /** Where it mounts, from \`mountPrefix\` — \`/offscale\` for ours, \`/p//\` for others. */ route: string; /** The panels it contributes. At least one, or discovery refuses the plugin. */ panels: AppRegistryMeta[]; /** How they are arranged. The shell renders \`WorkspaceView\` around them. */ layout: LayoutNode; }; export const plugins: GeneratedPlugin[] = [ ${entries.join('\n')} ]; `; mkdirSync(dirname(GENERATED_PATH), { recursive: true }); // Only write when it changed: an unchanged file keeps its mtime, which keeps the bundler's caches and // `bun --watch` from rebuilding for nothing on every refresh of the plugin list. const previous = existsSync(GENERATED_PATH) ? readFileSync(GENERATED_PATH, 'utf-8') : ''; if (previous !== body) writeFileSync(GENERATED_PATH, body); return body; } export type BuildResult = { ok: boolean; ms: number; outputs: number; error?: string }; /** * Rebuild the SPA into `build/`. * * ~1s measured on this machine for a 25MB bundle, which is why an install can rebuild rather than asking * the owner to restart. Failure is returned rather than thrown: a plugin whose frontend will not compile * must leave the previous build in place and serving, not take the UI down with it. */ /** * Everything a failed build knows, as one string. * * `AggregateError` — which is what a bundler failure is — keeps its diagnostics in `errors` and puts a * generic summary in `message`. Reading `.message` gives you "Bundle failed", which is true and useless. */ function describeBuildFailure(err: unknown): string { if (err && typeof err === 'object' && 'errors' in err && Array.isArray((err as AggregateError).errors)) { const parts = (err as AggregateError).errors.map((e) => String(e)); if (parts.length) return parts.join('; ').slice(0, 1000); } return err instanceof Error ? err.message : String(err); } export async function rebuildFrontend(): Promise { const started = Date.now(); try { // Built into a staging directory and swapped, never in place. // // Two reasons, one of which was learned the hard way. Chunks are content-hashed, so building over the // old ones leaves them behind and `build/` grows ~20MB an install — but clearing FIRST means a failed // build leaves nothing at all, which is the opposite of what this function promises. And clearing // first is a race: two builds overlapping had one process's `rm` delete the other's HTML, leaving a // JS and a CSS file with no shell and a 503 that looked like a build failure when the build had // succeeded. // // Staging fixes both. `build/` is only ever replaced by a complete, successful build. if (existsSync(STAGING_DIR)) rmSync(STAGING_DIR, { recursive: true, force: true }); const result = await Bun.build({ entrypoints: [HTML_ENTRY], outdir: STAGING_DIR, /** * WITHOUT THIS, EVERY BUILD FAILURE READS `Bundle failed` AND NOTHING ELSE. * * `Bun.build` THROWS on failure by default — an `AggregateError` whose own message is the string * "Bundle failed" and whose `errors` array holds the real diagnostics. So the `result.success` * branch below was unreachable, the catch reported the wrapper's summary, and the actual reason * (`ModuleNotFound resolving "./Plugins.gen"`, a CSS `url()` that cannot be resolved, a syntax * error with a file and a line) was thrown away at the moment it was most needed. * * Diagnosed by building a file that does not exist and printing the error's shape, after it cost * twenty minutes of bisecting a themes refactor by hand. `throw: false` makes the returned result * carry the logs, which is what the code below always assumed. */ throw: false, // WITHOUT THIS THE APP RENDERS UNSTYLED. // // `bunfig.toml` declares `[serve.static] plugins = ["bun-plugin-tailwind"]`, and that applies to // Bun's static SERVING — the HTML-import path — not to a programmatic `Bun.build()`. So the first // build produced the xterm CSS and no Tailwind at all: layout intact, every utility class missing. // A plugin list is not inherited from bunfig; it has to be passed here. plugins: [tailwind], minify: process.env.NODE_ENV === 'production', sourcemap: 'none', }); if (!result.success) { return { ok: false, ms: Date.now() - started, outputs: 0, // 1000 rather than 500: a single bundler diagnostic with a file, a line and a code frame is // routinely longer than 500 characters, and truncating mid-path leaves it unactionable. error: result.logs .map((l) => String(l)) .join('; ') .slice(0, 1000), }; } // The shell must exist before anything is swapped in — a build reporting success without one is how // the 503 above happened, and serving a directory with no HTML is worse than serving the old one. if (!existsSync(join(STAGING_DIR, SHELL_FILE))) { return { ok: false, ms: Date.now() - started, outputs: 0, error: `build produced no ${SHELL_FILE}` }; } // Make the shell's asset URLs ABSOLUTE. // // Bun emits `./chunk-….js` for an HTML entrypoint and `publicPath` does not change it. A relative URL // resolves against the CURRENT path, so it is correct at `/` and wrong at `/example/deeper`, where the // browser asks for `/example/chunk-….js`. Every deep link more than one segment deep would fail to // boot. Rewritten here rather than worked around in the server, because the wrong URL is in the file. const shellPath = join(STAGING_DIR, SHELL_FILE); const shell = readFileSync(shellPath, 'utf-8'); const absolute = shell.replace(/(src|href)="\.\//g, '$1="/'); if (absolute !== shell) writeFileSync(shellPath, absolute); if (existsSync(BUILD_DIR)) rmSync(BUILD_DIR, { recursive: true, force: true }); renameSync(STAGING_DIR, BUILD_DIR); return { ok: true, ms: Date.now() - started, outputs: result.outputs.length }; } catch (err) { return { ok: false, ms: Date.now() - started, outputs: 0, error: err instanceof Error ? err.message : String(err), }; } } export { BUILD_DIR, GENERATED_PATH };