Every failed frontend build has reported `Bundle failed` and nothing else, for as long as anyone remembers. The cause is one line that was never there: `Bun.build` THROWS on failure by default. It throws an `AggregateError` whose own message is the string "Bundle failed" and whose `errors` array holds the real diagnostics. So the carefully written `result.success` branch — with its comment about what a failed build must not do — was unreachable, and the catch dutifully reported `err.message`. `throw: false` makes the returned result carry `logs`, which is what the code below it always assumed. The catch keeps a fallback that unpacks `AggregateError.errors` anyway: the build should no longer throw, and the failure mode this hides is exactly the one that has been wasting people's evenings. Truncation went 500 -> 1000 characters. A single bundler diagnostic with a file, a line and a code frame routinely exceeds 500, and cutting mid-path leaves it unactionable. Proven by breaking the app on purpose: before: Bundle failed after: ResolveMessage: Could not resolve: "./this-module-does-not-exist" ── The first attempt at that proof was worthless ── An UNUSED import reported `ok: true`, which briefly looked like a much worse bug — a broken app building clean. It is not: an unused import is elided by the TypeScript transform before anything tries to resolve it, so there was no error to report. The break only becomes a break once the import is used. Worth recording because "I broke it and the build passed" is an alarming sentence to arrive at by accident. Also verified that a failed build leaves `build/` alone — the staging swap does what its comment claims, so a bad build cannot take the running app down with it. This one shows up in the UI too: the Rebuild button in Settings -> System already displays `error`, so it stops saying "Rebuild failed" and starts naming the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
10 KiB
TypeScript
212 lines
10 KiB
TypeScript
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/<publisher>/<name>\` 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<BuildResult> {
|
|
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 };
|