a plugin's frontend, generated and rebuilt without a restart
the last piece. installing a plugin now brings its UI with it.
a bundler cannot follow import(runtimeString), so which plugins have a frontend
cannot be answered from the database at render time — it has to be written into
source first. Plugins.gen.tsx is that file: concrete imports, generated from
what is installed, gitignored because it describes THIS machine.
App.tsx keeps its core routes and gains one map. the wildcard hands the whole
subtree to the plugin's own router, which react-router nests natively.
serving moved to build/ in production. the html import is bundled once when the
module graph loads and can never change after, which is precisely why a plugin's
frontend needed a restart; Bun.build measures ~900ms for a 25MB bundle, so an
install can just rebuild. development keeps the html import, because that is
what gives HMR and bun --watch restarts on every source change anyway.
verified end to end against a running server, no restart at any point: install
regenerated the module, rebuilt the bundle (chunk hash changed), and the
plugin's own markup was in it; /example and /example/deeper both served; disable
took it back out of both the module and the bundle and 404'd the api; enable put
it back.
three things worth recording because they were found rather than reasoned:
the shell output is named after the ENTRYPOINT — index.gen.html, not index.html
— and naming: { entry: '[name].[ext]' } does not change it because [name] is
'index.gen'. found as a 503 on the first boot after the switch.
App.tsx already destructured a `plugins`, from useServerSettings — the DEAD
plugin system that scans a directory which does not exist and always returns [].
it silently shadowed the import. the new one is `installedPlugins` and says why.
seedAppRegistry takes plugin panels as an argument rather than importing them:
officerdev is a dependency of the shell, so importing upward would invert that.
756 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Routes, Route, Link } from 'react-router';
|
||||
|
||||
// The plugin's own router, mounted by the shell at `<prefix>/*` — 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 = () => (
|
||||
<div className="p-8">
|
||||
<h1 className="text-xl font-semibold text-duck-dark">Example plugin</h1>
|
||||
<p className="mt-2 text-sm text-duck-dark/60">
|
||||
Rendered from <code>plugins/example/web/Router.tsx</code>, compiled into the shell's bundle by the generated{' '}
|
||||
<code>Plugins.gen.tsx</code>.
|
||||
</p>
|
||||
<Link className="mt-4 inline-block text-sm text-duck-teal underline" to="/example/deeper">
|
||||
A nested route →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Deeper = () => (
|
||||
<div className="p-8">
|
||||
<h1 className="text-xl font-semibold text-duck-dark">Nested</h1>
|
||||
<p className="mt-2 text-sm text-duck-dark/60">Proof the wildcard mount hands the whole subtree to the plugin.</p>
|
||||
<Link className="mt-4 inline-block text-sm text-duck-teal underline" to="/example">
|
||||
← back
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ExampleRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/deeper" element={<Deeper />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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() {
|
||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
|
||||
{/* 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) => (
|
||||
<Route key={plugin.appName} path={`${plugin.route}/*`} element={<plugin.Router />} />
|
||||
))}
|
||||
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||
|
||||
@@ -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')!;
|
||||
|
||||
+48
-4
@@ -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<Response> => {
|
||||
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`.
|
||||
//
|
||||
|
||||
@@ -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/<publisher>/<name>\` 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<BuildResult> {
|
||||
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 };
|
||||
@@ -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<RefreshResult> {
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user