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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user