Files
platform/src/servers/plugins/generate.ts
T
pastilhasandClaude Opus 5 06bed42ace a plugin's frontend gets the same defines as the platform's own
One line in a vendored plugin file took the whole app down. scripts/build/web.ts
defines process.env.NODE_ENV for the platform bundle; the plugin frontend build
in generate.ts defined nothing, so any process.* in a plugin's web code 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 a blank platform rather than a broken panel.

Hit on macbookpro after installing m3e-canvas, which carries four files from a
Next.js original reading process.env.NEXT_PUBLIC_BASE_PATH. This machine was
unaffected only because the plugin is on disk but not installed, so it never
entered Plugins.gen.tsx.

Defines process.env.NODE_ENV for parity and process.env to ({}) for the rest,
so every other process.env.X becomes undefined — which is what the `?? ''` in
that vendored code already expects. Longest match wins; verified against bun
build rather than assumed. Fixed in the build rather than in the vendored tree,
because vendored code stays close to upstream here and this protects every
plugin instead of the one that hit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-09-22 13:16:52 +01:00

282 lines
14 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[] = [];
// 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/<id>`) 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 — \`<appName>\` for a single-screen plugin, \`<appName>/<segment>\` 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<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',
/*
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 };