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[] = []; // One entry per SCREEN, not per plugin. Almost every plugin has exactly one and this reads the same as // it always did; caldav has two, so it emits two routes, two layout keys and two dock tiles from one // install. The alias counter runs over screens for that reason — two screens of one plugin must not // share an import name. /* File-browser actions, one block per PLUGIN rather than per screen. The context menu is global — it is the same menu wherever the browser is mounted — so an action belongs to the plugin, not to one of its screens. A plugin with two screens contributes its actions once. */ const actionImports: string[] = []; const actionEntries: string[] = []; let a = 0; for (const { plugin } of withWeb) { if (!plugin.web?.fileActions) continue; const alias = `Actions${a}`; a += 1; actionImports.push(`import { fileActions as ${alias} } from '${specifier(plugin.web.fileActions)}';`); actionEntries.push(` ...${alias},`); } let i = 0; for (const { plugin } of withWeb) { for (const screen of plugin.web!.screens) { const alias = `Plugin${i}`; i += 1; imports.push(`import { appRegistryMetas as ${alias}Panels } from '${specifier(screen.panels)}';`); imports.push(`import { defaultLayout as ${alias}Layout } from '${specifier(screen.layout)}';`); // The screen id is the LAYOUT KEY (`screens/plugin/`) and the panel allow-list's owner, so two // screens of one plugin must not share it — otherwise they persist over each other's sizes and each // would accept the other's panels. const screenId = screen.segment ? `${plugin.appName}/${screen.segment}` : plugin.appName; const route = screen.segment ? `/${screen.segment}` : mountPrefix(plugin); entries.push( ` { appName: '${plugin.appName}', screenId: '${screenId}', route: '${route}', panels: ${alias}Panels, layout: ${alias}Layout },`, ); } } const body = `${HEADER} import type { AppRegistryMeta, FileAction, LayoutNode } from 'officerdev'; ${imports.join('\n')} ${actionImports.join('\n')} /** * Context-menu actions plugins contribute to the file browser. * * Flat and global: the menu is the same wherever the browser is mounted, so these are not scoped to a * screen. Each decides for itself whether it applies to what was selected. */ export const pluginFileActions: FileAction[] = [ ${actionEntries.join('\n')} ]; export type GeneratedPlugin = { /** The plugin this screen belongs to. Two screens of one plugin share it. */ appName: string; /** * This SCREEN's id — \`\` for a single-screen plugin, \`/\` for one of several. * * It is the layout persistence key and the panel allow-list's owner, so it has to be unique per screen * rather than per plugin: sharing it would have two screens persisting over each other's sizes. */ screenId: string; /** Where it mounts — \`mountPrefix\` normally, or a first-party plugin's chosen segment. */ 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', /* WITHOUT THIS A PLUGIN CAN TAKE THE WHOLE APP DOWN WITH ONE LINE. `scripts/build/web.ts` — the platform's own bundle — defines `process.env.NODE_ENV`. This build defined nothing, so any `process.*` in a plugin's frontend was emitted verbatim into a browser chunk and threw `ReferenceError: process is not defined` at module evaluation. One throwing module fails the whole graph, so the symptom is not a broken panel, it is a blank platform. Reached by vendored code rather than by anyone writing Node in a component: m3e-canvas carries four files from a Next.js original that read `process.env.NEXT_PUBLIC_BASE_PATH`. Vendored trees stay close to upstream here (see the hls.js note in CLAUDE.md), so the build is the right place to absorb it — and it protects every plugin rather than the one that happened to hit it. Both keys, and the order does not matter: the longest match wins, so `NODE_ENV` keeps its real value and every other `process.env.X` becomes `({}).X`, which is `undefined` — exactly what the `?? ''` in that vendored code expects. Verified against `bun build` rather than assumed. */ define: { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV === 'production' ? 'production' : 'development'), 'process.env': '({})', }, }); 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 };