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,87 @@
|
||||
import { afterAll, describe, expect, it } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { discoverPlugins } from './discover';
|
||||
|
||||
// Discovery against a real tree, because every assertion here is about the FILESYSTEM being the
|
||||
// declaration. Mocking `existsSync` would test the mock.
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'officer-plugins-'));
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const MANIFEST = (over = '') => `export const manifest = {
|
||||
publisher: 'officerdev', version: '1.0.0', platform: '>=1.0.0',
|
||||
label: 'X', summary: 'x', icon: 'Network', color: '#fff',
|
||||
permissions: [], ${over}
|
||||
};`;
|
||||
|
||||
function plant(appName: string, files: Record<string, string>) {
|
||||
const dir = join(root, appName);
|
||||
for (const [rel, body] of Object.entries(files)) {
|
||||
const full = join(dir, rel);
|
||||
mkdirSync(join(full, '..'), { recursive: true });
|
||||
writeFileSync(full, body);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
plant('full', {
|
||||
'manifest.ts': MANIFEST(),
|
||||
'api/router.ts': 'export const router = {};',
|
||||
'db/schema.ts': 'export const t = {};',
|
||||
'sidecar/index.ts': 'export {};',
|
||||
'web/Router.tsx': 'export default () => null;',
|
||||
'web/panels.ts': 'export const appRegistryMetas = [];',
|
||||
});
|
||||
plant('bare', { 'manifest.ts': MANIFEST() });
|
||||
plant('nodeish', { 'manifest.ts': MANIFEST(), 'sidecar/index.mjs': 'export {};' });
|
||||
plant('broken', { 'manifest.ts': 'export const manifest = { publisher: 1 };' });
|
||||
plant('nomanifest', { 'api/router.ts': 'export const router = {};' });
|
||||
plant('_scratch', { 'manifest.ts': MANIFEST() });
|
||||
|
||||
describe('discoverPlugins', () => {
|
||||
it('reads what the tree declares, and nothing more', async () => {
|
||||
const { plugins } = await discoverPlugins(root);
|
||||
const full = plugins.find((p) => p.appName === 'full')!;
|
||||
expect(full.api).toContain('api/router.ts');
|
||||
expect(full.schema).toContain('db/schema.ts');
|
||||
expect(full.sidecar).toEqual({ script: join(root, 'full/sidecar/index.ts'), runtime: 'bun' });
|
||||
expect(full.web?.panels).toContain('web/panels.ts');
|
||||
});
|
||||
|
||||
it('a manifest alone is a valid plugin — every other part is optional', async () => {
|
||||
const { plugins } = await discoverPlugins(root);
|
||||
const bare = plugins.find((p) => p.appName === 'bare')!;
|
||||
expect([bare.api, bare.schema, bare.sidecar, bare.web]).toEqual([null, null, null, null]);
|
||||
});
|
||||
|
||||
// The runtime is the extension, not a field, so it cannot contradict the file it describes.
|
||||
it('reads the runtime off the extension', async () => {
|
||||
const { plugins } = await discoverPlugins(root);
|
||||
expect(plugins.find((p) => p.appName === 'nodeish')!.sidecar?.runtime).toBe('node');
|
||||
});
|
||||
|
||||
it('takes the app name from the directory, so it cannot disagree with where the code sits', async () => {
|
||||
const { plugins } = await discoverPlugins(root);
|
||||
expect(plugins.map((p) => p.appName)).toContain('full');
|
||||
});
|
||||
|
||||
// The property that matters most: one bad plugin must not take the platform down, or hide the good
|
||||
// ones beside it. "Broken, and here is why" is renderable; a failed boot is only greppable.
|
||||
it('collects broken plugins instead of throwing', async () => {
|
||||
const { plugins, broken } = await discoverPlugins(root);
|
||||
expect(broken.map((b) => b.appName).sort()).toEqual(['broken', 'nomanifest']);
|
||||
expect(broken.find((b) => b.appName === 'nomanifest')!.error).toContain('no manifest.ts');
|
||||
expect(plugins.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('skips underscore and dot directories, which are scratch space', async () => {
|
||||
const { plugins, broken } = await discoverPlugins(root);
|
||||
expect([...plugins, ...broken].map((p) => p.appName)).not.toContain('_scratch');
|
||||
});
|
||||
|
||||
it('is empty, not an error, when there is no plugins directory at all', async () => {
|
||||
expect(await discoverPlugins(join(root, 'does-not-exist'))).toEqual({ plugins: [], broken: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user