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:
+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`.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user