diff --git a/.gitignore b/.gitignore index c6fedb0b..caa030b9 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,8 @@ scripts/setup/officer-setup/.setup-progress # the repository has no ecosystem file at all any more, and the next machine # generates its own. See scripts/setup/officer-setup/lib/services.sh. ecosystem.config.cjs + +# The built SPA and the generated plugin module — both describe THIS install's plugin set and are +# rewritten on every install. See servers/plugins/generate.ts. +build/ +src/apps/officer-web/Plugins.gen.tsx diff --git a/plugins/example/web/Router.tsx b/plugins/example/web/Router.tsx new file mode 100644 index 00000000..ce388886 --- /dev/null +++ b/plugins/example/web/Router.tsx @@ -0,0 +1,36 @@ +import { Routes, Route, Link } from 'react-router'; + +// The plugin's own router, mounted by the shell at `/*` — so everything below this point is the +// plugin's, and react-router nests it natively. The shell knows the prefix; this file does not need to. + +const Home = () => ( +
+

Example plugin

+

+ Rendered from plugins/example/web/Router.tsx, compiled into the shell's bundle by the generated{' '} + Plugins.gen.tsx. +

+ + A nested route → + +
+); + +const Deeper = () => ( +
+

Nested

+

Proof the wildcard mount hands the whole subtree to the plugin.

+ + ← back + +
+); + +export default function ExampleRouter() { + return ( + + } /> + } /> + + ); +} diff --git a/plugins/example/web/panels.ts b/plugins/example/web/panels.ts new file mode 100644 index 00000000..02ba7915 --- /dev/null +++ b/plugins/example/web/panels.ts @@ -0,0 +1,10 @@ +import { Puzzle } from 'lucide-react'; +import type { AppRegistryMeta } from 'officerdev'; +import ExampleRouter from './Router'; + +// Panels this plugin contributes to the workspace registry. The shell passes them to `seedAppRegistry` +// from the generated module — it never imports this file directly, because officerdev is a dependency of +// the shell and importing upward would invert that. +export const appRegistryMetas: AppRegistryMeta[] = [ + { key: 'example-panel', name: 'Example', icon: Puzzle, component: ExampleRouter, availableOnPanel: true }, +]; diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 3a576cad..5238d6bd 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -5,6 +5,10 @@ import { useAuth } from 'hooks/useAuth'; import { useServerSettings } from 'state/useServerSettings'; import { useServerEnvironment } from 'state/useServerEnvironment'; import { useInitialData } from '@/state/useInitialData'; +// `installedPlugins`, not `plugins`: App.tsx already destructures a `plugins` from useServerSettings(), +// which is the DEAD plugin system — /server-settings/plugins scans src/workspaces/plugins/, a directory +// that does not exist, so it is always []. Different thing entirely; see docs/offscale-plugin.md. +import { plugins as installedPlugins } from './Plugins.gen'; export function App() { const { isLoading, isAuthenticated } = useAuth(); @@ -65,6 +69,12 @@ export function App() { } /> } /> } /> + {/* Installed plugins. Core routes above stay hand-written; everything below is generated from + what is installed, because a bundler cannot follow a runtime import specifier. The wildcard + hands the whole subtree to the plugin's own router, which react-router nests natively. */} + {installedPlugins.map((plugin) => ( + } /> + ))} } /> } /> } /> diff --git a/src/apps/officer-web/frontend.tsx b/src/apps/officer-web/frontend.tsx index 90c94c4c..7a6db451 100644 --- a/src/apps/officer-web/frontend.tsx +++ b/src/apps/officer-web/frontend.tsx @@ -3,6 +3,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App'; import { seedAppRegistry, seedWidgetRegistry } from 'officerdev'; +import { plugins } from './Plugins.gen'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ColorModeProvider } from '@/components/ui/ThemeProvider'; import { I18nBridge } from '@/lib/I18nBridge'; @@ -20,7 +21,11 @@ const queryClient = new QueryClient({ // Before the first render, not from a component inside it: a panel that renders before its registry is // populated draws an empty box, and `useGlobal`'s initialData gives no second chance to fill it. -seedAppRegistry(queryClient); +// Panels contributed by installed plugins, from the generated module. See servers/plugins/generate.ts. +seedAppRegistry( + queryClient, + plugins.flatMap((p) => p.panels), +); seedWidgetRegistry(queryClient); const elem = document.getElementById('root')!; diff --git a/src/server.tsx b/src/server.tsx index 1f37b649..2439ade3 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -1,9 +1,11 @@ import './servers/bootstrap'; import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; +import { join } from 'node:path'; import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono'; import { assertCapabilityTotality } from './servers/capabilities/totality'; -import { refreshPluginMounts } from './servers/plugins/mount'; +import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount'; +import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate'; import { assertInstallLayout } from './servers/data-path'; import { PORT } from './servers/officer-url.mjs'; import { assertSecretsClosed } from './servers/os-user'; @@ -172,7 +174,18 @@ await assertSecretsClosed(process.cwd()); // Failure is logged and survived: a plugin that will not load must not stop the platform from starting, // and `refreshPluginMounts` already isolates one bad plugin from the rest. try { - const { mounted, broken, rejected } = await refreshPluginMounts(); + // Generate first, then build, then mount. The generated module is what the bundler reads, so writing it + // after the build would produce a bundle describing the previous plugin set. + const { states } = await snapshotPlugins(); + generatePluginsModule(states); + + const built = await rebuildFrontend(); + if (built.ok) console.log(`[plugins] frontend built in ${built.ms}ms (${built.outputs} outputs)`); + else console.error('[plugins] frontend build FAILED —', built.error); + + // `skipFrontend`, because the build above already used the same snapshot. Rebuilding here would cost a + // second of start-up to produce a byte-identical bundle. + const { mounted, broken, rejected } = await refreshPluginMounts({ skipFrontend: true }); if (mounted.length) console.log(`[plugins] mounted ${mounted.join(', ')}`); if (broken.length) console.error(`[plugins] unreadable: ${broken.join(', ')}`); if (rejected.length) console.error(`[plugins] permission keys refused: ${rejected.join(', ')}`); @@ -180,6 +193,38 @@ try { console.error('[plugins] none mounted —', err instanceof Error ? err.message : err); } +// ── Serving the SPA ── +// +// In PRODUCTION the bundle comes from `build/`, rebuilt whenever a plugin is installed. That is what makes +// a plugin's frontend appear without restarting the process: the HTML import below is bundled once when +// the module graph loads and can never change afterwards. +// +// In DEVELOPMENT the HTML import is kept, because it is what gives HMR — and `bun --watch` restarts on +// every source change anyway, so nothing is gained by building. The two paths differ only in where the +// bytes come from; the generated `Plugins.gen.tsx` is the same file in both. +const serveBuilt = async (req: Request): Promise => { + const path = new URL(req.url).pathname; + + // An asset if it exists, the shell otherwise. A client-side route like `/offscale/servers` is not a file + // and must return index.html, which is what makes deep links work at all. + const asset = Bun.file(join(BUILD_DIR, path === '/' ? SHELL_FILE : path.slice(1))); + if (path !== '/' && (await asset.exists())) return new Response(asset); + + const shell = Bun.file(join(BUILD_DIR, SHELL_FILE)); + if (!(await shell.exists())) { + // The build failed or never ran. Say so rather than 404ing, because a blank page here looks like a + // broken deploy and this is recoverable by restarting or reinstalling the plugin that broke it. + return new Response('The interface has not been built. Check the server log for a build failure.', { + status: 503, + headers: { 'content-type': 'text/plain' }, + }); + } + return new Response(shell, { headers: { 'content-type': 'text/html' } }); +}; + +const isProduction = process.env.NODE_ENV === 'production'; +const spaRoutes = isProduction ? { '/': serveBuilt, '/*': serveBuilt } : { '/': officerWeb, '/*': officerWeb }; + async function upgradeWs( req: Request, server: any, @@ -335,8 +380,7 @@ const server = serve({ // '/notifications/*': honoServer.fetch, // '/icons/*': honoServer.fetch, // '/events/*': honoServer.fetch, - '/': officerWeb, - '/*': officerWeb, + ...spaRoutes, '/api': honoServer.fetch, // A CLOSURE, deliberately, and not the bound `honoServer.fetch`. // diff --git a/src/servers/plugins/generate.ts b/src/servers/plugins/generate.ts new file mode 100644 index 00000000..f0bb4dcb --- /dev/null +++ b/src/servers/plugins/generate.ts @@ -0,0 +1,146 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +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 comes from `/api/user/capabilities` like every +// other plugin manifest, 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'); +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 ${alias}Router from '${specifier(plugin.web!.router)}';`); + + let panels = '[]'; + if (plugin.web!.panels) { + imports.push(`import { appRegistryMetas as ${alias}Panels } from '${specifier(plugin.web!.panels)}';`); + panels = `${alias}Panels`; + } + + entries.push( + ` { appName: '${plugin.appName}', route: '${mountPrefix(plugin)}', Router: ${alias}Router, panels: ${panels} },`, + ); + }); + + const body = `${HEADER} +import type { ComponentType } from 'react'; +import type { AppRegistryMeta } from 'officerdev'; +${imports.join('\n')} + +export type GeneratedPlugin = { + appName: string; + /** Where it mounts, from \`mountPrefix\` — \`/offscale\` for ours, \`/p//\` for others. */ + route: string; + /** Mounted at \`\${route}/*\`, so the plugin's own router owns everything beneath it. */ + Router: ComponentType; + /** Panel apps it contributes to the registry. */ + panels: AppRegistryMeta[]; +}; + +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. + */ +export async function rebuildFrontend(): Promise { + const started = Date.now(); + try { + const result = await Bun.build({ + entrypoints: [HTML_ENTRY], + outdir: BUILD_DIR, + minify: process.env.NODE_ENV === 'production', + sourcemap: 'none', + }); + if (!result.success) { + return { + ok: false, + ms: Date.now() - started, + outputs: 0, + error: result.logs + .map((l) => String(l)) + .join('; ') + .slice(0, 500), + }; + } + 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 }; diff --git a/src/servers/plugins/mount.ts b/src/servers/plugins/mount.ts index cc5ad41e..e807ad61 100644 --- a/src/servers/plugins/mount.ts +++ b/src/servers/plugins/mount.ts @@ -4,6 +4,7 @@ import { rebuildHonoApp } from '../hono'; import { setPluginCapabilities, type Capability } from '../capabilities/registry'; import { discoverPlugins } from './discover'; import { mountPrefix, type DiscoveredPlugin } from './manifest'; +import { generatePluginsModule, rebuildFrontend } from './generate'; // Turning what is on disk plus what is in the database into a mounted application. // @@ -120,7 +121,15 @@ function pluginCapabilities(state: PluginState): Capability[] { * all, so nothing is mutated: a fresh app is built and `honoServer` is reassigned. `server.tsx` serves it * through a closure, which is what makes the reassignment take effect. */ -export async function refreshPluginMounts(): Promise<{ mounted: string[]; broken: string[]; rejected: string[] }> { +export type RefreshResult = { + mounted: string[]; + broken: string[]; + rejected: string[]; + /** Absent when the frontend did not need rebuilding, or when `skipFrontend` was asked for. */ + frontend?: { ok: boolean; ms: number; error?: string }; +}; + +export async function refreshPluginMounts(options: { skipFrontend?: boolean } = {}): Promise { const snapshot = await snapshotPlugins(); // Capabilities BEFORE routes. The capability gate runs ahead of every router, so a route mounted without @@ -134,5 +143,22 @@ export async function refreshPluginMounts(): Promise<{ mounted: string[]; broken const mounted = await mountablePlugins(snapshot); rebuildHonoApp(mounted); - return { mounted: mounted.map((m) => m.prefix), broken: snapshot.broken.map((b) => b.appName), rejected }; + + // The frontend half. `Plugins.gen.tsx` is what the bundler reads, so it is written first and the SPA is + // rebuilt from it — ~1s for a 25MB bundle, which is why an install can do this rather than ask for a + // restart. Skipped at boot, where the build already happened and repeating it would delay `serve()`. + let frontend: RefreshResult['frontend']; + if (!options.skipFrontend) { + generatePluginsModule(snapshot.states); + const built = await rebuildFrontend(); + frontend = { ok: built.ok, ms: built.ms, ...(built.error ? { error: built.error } : {}) }; + if (!built.ok) console.error('[plugins] frontend rebuild failed:', built.error); + } + + return { + mounted: mounted.map((m) => m.prefix), + broken: snapshot.broken.map((b) => b.appName), + rejected, + ...(frontend ? { frontend } : {}), + }; } diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index e1dae034..045d4e0f 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -1,3 +1,4 @@ +import type { AppRegistryMeta } from './useAppRegistry/useAppRegistry'; import { appRegistryMetas as appStoreMetas } from '../apps/AppStore'; import { appRegistryMetas as pluginsMetas } from '../apps/Plugins'; import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser'; @@ -63,6 +64,13 @@ export const apps = [ * every panel app, and every panel app imports the Workspace framework. Keeping the call in `frontend.tsx` * — the one module that is nobody's dependency — is what keeps that from becoming an import cycle. */ -export const seedAppRegistry = (queryClient: QueryClient) => { - queryClient.setQueryData(globalQueryKey('APP_REGISTRY'), metasToRegistry(apps)); +/** + * `pluginPanels` comes from the generated `Plugins.gen.tsx`, passed in rather than imported. + * + * The direction matters: this package is a dependency of the shell, so importing a file from + * `apps/officer-web` would invert that and create a cycle. The shell knows what is installed; this only + * knows how to register a panel. + */ +export const seedAppRegistry = (queryClient: QueryClient, pluginPanels: AppRegistryMeta[] = []) => { + queryClient.setQueryData(globalQueryKey('APP_REGISTRY'), metasToRegistry([...apps, ...pluginPanels])); };