the hono app is built, not assembled once

first piece of the plugin system: the platform can now be rebuilt with a
different set of plugins mounted, at runtime, without restarting.

hono cannot do this the obvious way. its default SmartRouter throws "Can not add
a route since the matcher is already built" the moment a route is added after
serving begins, RegExpRouter does the same, and hono has no api to REMOVE a
route at all — so uninstall was impossible even with TrieRouter, which does
allow adding. tested all four.

so nothing is added to a live app. buildHonoApp(plugins) constructs a fresh one
and honoServer is reassigned, which keeps the default fast router and makes
uninstall expressible. server.tsx now serves it through a closure rather than
the bound honoServer.fetch — that one line is the whole mechanism, since the
bound method would capture whichever app existed at serve() and every rebuild
would silently do nothing.

buildHonoApp is pure: everything it needs arrives as an argument, so an app for
a hypothetical plugin set can be built without a database, a filesystem or a
running server.

alongside it, discovery. plugins live at platform/plugins/<app-name>/ — inside
the repo, because bun links the workspace packages into the root node_modules
and that is what lets a plugin author write `import { useClient } from
'hooks/useClient'` with no publishing and no version negotiation. verified with
Bun.resolveSync from a directory there.

discovery is by convention and presence is the declaration: api/router.ts,
db/schema.ts, sidecar/index.ts, web/Router.tsx. the app name comes from the
directory, so it cannot disagree with where the code sits, and the sidecar
runtime comes from the extension — .mjs is node, .ts is bun — which is already
the rule here and cannot contradict the file it describes.

a broken plugin is collected, never thrown: one unreadable manifest must not
stop the boot or hide the nine beside it that are fine.

verified by booting the refactored server on a spare port — /api answers 200,
protected routes still 401. full suite: 719 pass, and the same 10 failures as
before this change (8 in capabilities, plus cliamp and pty), stash-verified
earlier as pre-existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 20:16:11 +00:00
co-authored by Claude Opus 5
parent b2349b5480
commit 2e6c263751
7 changed files with 617 additions and 106 deletions
+125
View File
@@ -0,0 +1,125 @@
import { existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { PLATFORM_DIR } from '../data-path';
import { manifestProblems, type DiscoveredPlugin, type PluginManifest } from './manifest';
// Finding plugins on disk.
//
// They live at `<platform>/plugins/<app-name>/` — INSIDE the repository, not beside it, and that is what
// makes the whole developer story work. Bun links the workspace packages into the root `node_modules`, so
// anything under the repo can `import { useClient } from 'hooks/useClient'` with no publishing, no package
// registry and no version negotiation. A plugin author clones the platform, drops their plugin in, and
// runs it in dev — the WordPress model — and the same tree is what the server builds from.
//
// Verified: `Bun.resolveSync('hooks/useClient', '<platform>/plugins/anything')` resolves.
//
// Discovery is by CONVENTION. Presence is the declaration:
//
// manifest.ts required — everything a directory listing cannot say
// api/router.ts a backend router
// db/schema.ts tables
// sidecar/index.ts a process (`.mjs` instead means node — see below)
// web/Router.tsx a frontend
// web/panels.ts panel apps
//
// Nothing here reads the database. This answers "what is on disk", which is a different question from
// "what is installed" — the install table answers that, and the two disagreeing is a state the app store
// has to render rather than a bug to prevent.
/** Where plugins live. Inside the repo, so the workspace packages resolve. */
export const PLUGINS_DIR = join(PLATFORM_DIR, 'plugins');
/**
* The runtime is the file extension, not a manifest field.
*
* `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun. Implicit, but it is the rule this
* repository already follows — `officer-pty` is `pty/index.mjs` under node because node-pty is a native
* module built against Node's ABI, and everything else is bun. Better than a field that can contradict
* the file it describes.
*/
function findSidecar(dir: string): DiscoveredPlugin['sidecar'] {
const ts = join(dir, 'sidecar', 'index.ts');
if (existsSync(ts)) return { script: ts, runtime: 'bun' };
const mjs = join(dir, 'sidecar', 'index.mjs');
if (existsSync(mjs)) return { script: mjs, runtime: 'node' };
return null;
}
function findWeb(dir: string): DiscoveredPlugin['web'] {
const router = join(dir, 'web', 'Router.tsx');
if (!existsSync(router)) return null;
const panels = join(dir, 'web', 'panels.ts');
return { router, panels: existsSync(panels) ? panels : null };
}
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
/**
* Read one plugin directory.
*
* Throws with every problem at once rather than the first, because a manifest fixed one field per attempt
* is a manifest nobody finishes.
*/
export async function loadPlugin(dir: string, appName: string): Promise<DiscoveredPlugin> {
const manifestPath = join(dir, 'manifest.ts');
if (!existsSync(manifestPath)) throw new Error(`${appName}: no manifest.ts`);
let manifest: PluginManifest | null = null;
try {
const module = (await import(manifestPath)) as { manifest?: PluginManifest };
manifest = module.manifest ?? null;
} catch (err) {
throw new Error(`${appName}: manifest.ts failed to load — ${err instanceof Error ? err.message : String(err)}`);
}
const problems = manifestProblems(appName, manifest);
if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`);
return {
appName,
dir,
manifest: manifest as PluginManifest,
api: fileOrNull(join(dir, 'api', 'router.ts')),
schema: fileOrNull(join(dir, 'db', 'schema.ts')),
sidecar: findSidecar(dir),
web: findWeb(dir),
};
}
export type DiscoveryResult = {
plugins: DiscoveredPlugin[];
/** Directories that look like plugins but could not be read. Reported, never thrown — see below. */
broken: { appName: string; error: string }[];
};
/**
* Every plugin directory under `PLUGINS_DIR`.
*
* A broken plugin is COLLECTED, not thrown. One unreadable manifest must not stop the platform from
* booting or hide the nine plugins beside it that are fine — and "this one is broken, here is why" is
* something the app store can render, where a failed boot is something only a log can.
*/
export async function discoverPlugins(root: string = PLUGINS_DIR): Promise<DiscoveryResult> {
if (!existsSync(root)) return { plugins: [], broken: [] };
const plugins: DiscoveredPlugin[] = [];
const broken: { appName: string; error: string }[] = [];
for (const entry of readdirSync(root)) {
// `_`-prefixed directories are scratch space, and dotfiles are not plugins.
if (entry.startsWith('.') || entry.startsWith('_')) continue;
const dir = join(root, entry);
try {
if (!statSync(dir).isDirectory()) continue;
} catch {
continue;
}
try {
plugins.push(await loadPlugin(dir, entry));
} catch (err) {
broken.push({ appName: entry, error: err instanceof Error ? err.message : String(err) });
}
}
return { plugins: plugins.sort((a, b) => a.appName.localeCompare(b.appName)), broken };
}