Discover plugins at boot (todo §2); scan plugins/, import + validate each plugin.ts default export, fail loud on bad plugin/conflict
This commit is contained in:
64
src/discovery.test.ts
Normal file
64
src/discovery.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test, type TestContext } from "node:test";
|
||||
import { discoverPlugins } from "./discovery.ts";
|
||||
|
||||
// Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures
|
||||
// default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest.
|
||||
function scaffold(t: TestContext, files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = join(dir, rel);
|
||||
mkdirSync(dirname(full), { recursive: true });
|
||||
writeFileSync(full, content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const full = (id: string): string =>
|
||||
`export default { apiVersion: "1.0.0", nav: [{ id: "${id}:root", label: "${id}" }], ` +
|
||||
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
|
||||
|
||||
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
|
||||
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
|
||||
});
|
||||
|
||||
test("discovers each folder's manifest, sorted, id derived from the folder name", async (t) => {
|
||||
const dir = scaffold(t, { "beta/plugin.ts": full("beta"), "alpha/plugin.ts": full("alpha") });
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
|
||||
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta"]); // deterministic order
|
||||
assert.equal(plugins[0]?.apiVersion, "1.0.0");
|
||||
assert.equal(plugins[0]?.nav?.[0]?.label, "alpha");
|
||||
assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import
|
||||
});
|
||||
|
||||
// Every per-plugin problem and every error-level conflict aborts boot with a message naming it.
|
||||
const badCases: Array<{ name: string; files: Record<string, string>; match: RegExp }> = [
|
||||
{ name: "invalid folder name", files: { "Bad_Name/plugin.ts": full("x") }, match: /Bad_Name/ },
|
||||
{ name: "missing plugin.ts", files: { "broken/readme.txt": "x" }, match: /broken.*plugin\.ts/s },
|
||||
{ name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s },
|
||||
{ name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s },
|
||||
{ name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s },
|
||||
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s },
|
||||
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
||||
];
|
||||
|
||||
for (const c of badCases) {
|
||||
test(`fails loud: ${c.name}`, async (t) => {
|
||||
await assert.rejects(discoverPlugins({ dir: scaffold(t, c.files) }), c.match);
|
||||
});
|
||||
}
|
||||
|
||||
test("a shared permission token only warns — both plugins still load", async (t) => {
|
||||
const perm = `export default { apiVersion: "1.0.0", permissions: [{ token: "shared:read" }] };`;
|
||||
const dir = scaffold(t, { "x/plugin.ts": perm, "y/plugin.ts": perm });
|
||||
const warnings: string[] = [];
|
||||
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
||||
|
||||
assert.equal(plugins.length, 2);
|
||||
assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a permission-conflict warning");
|
||||
});
|
||||
96
src/discovery.ts
Normal file
96
src/discovery.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Plugin discovery (todo §2): scan plugins/, import each folder's plugin.ts default export,
|
||||
// validate it, assemble the loaded Plugin[]. The imperative shell over the pure rules in
|
||||
// plugin.ts (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin
|
||||
// problem and every error-level conflict is collected and thrown as one boot-stopping Error;
|
||||
// warn-level diagnostics (older-minor apiVersion, shared permission token) are logged, load
|
||||
// continues. The folder name is the id; mount/router wiring is the next §2 item.
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { checkApiVersion, findConflicts, isValidPluginId, type Plugin, type PluginManifest } from "./plugin.ts";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
// Default scan root — <repo>/plugins, i.e. the /app/plugins the container mounts (README).
|
||||
export const PLUGINS_DIR = join(rootDir, "plugins");
|
||||
|
||||
export interface DiscoverOptions {
|
||||
dir?: string;
|
||||
logger?: Pick<Console, "warn">; // warn-level diagnostics; defaults to console
|
||||
}
|
||||
|
||||
export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Plugin[]> {
|
||||
const dir = options.dir ?? PLUGINS_DIR;
|
||||
const logger = options.logger ?? console;
|
||||
if (!existsSync(dir)) return []; // a clean clone has no plugins/ yet — zero plugins is valid
|
||||
|
||||
const errors: string[] = [];
|
||||
const plugins: Plugin[] = [];
|
||||
|
||||
for (const id of pluginFolders(dir)) {
|
||||
const fail = (msg: string): number => errors.push(`plugins/${id}: ${msg}`);
|
||||
|
||||
if (!isValidPluginId(id)) {
|
||||
errors.push(`"${id}" is not a valid plugin folder name (lowercase a–z, digits, dashes)`);
|
||||
continue;
|
||||
}
|
||||
const file = join(dir, id, "plugin.ts");
|
||||
if (!existsSync(file)) { fail("no plugin.ts found"); continue; }
|
||||
|
||||
let mod: { default?: unknown };
|
||||
try {
|
||||
mod = (await import(pathToFileURL(file).href)) as { default?: unknown };
|
||||
} catch (err) {
|
||||
fail(`failed to import plugin.ts — ${messageOf(err)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const manifest = asManifest(mod.default);
|
||||
if (!manifest) { fail("plugin.ts must default-export a manifest object"); continue; }
|
||||
|
||||
const version = checkApiVersion(manifest.apiVersion);
|
||||
if (version.level === "refuse") { fail(version.message); continue; }
|
||||
if (version.level === "warn") logger.warn(`[plugins] ${id}: ${version.message}`);
|
||||
|
||||
const shape = shapeError(manifest);
|
||||
if (shape) { fail(shape); continue; }
|
||||
|
||||
plugins.push({ ...manifest, id }); // identity is the folder, not the manifest
|
||||
}
|
||||
|
||||
for (const conflict of findConflicts(plugins)) {
|
||||
if (conflict.level === "error") errors.push(conflict.message);
|
||||
else logger.warn(`[plugins] ${conflict.message}`);
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
throw new Error(`Plugin discovery failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
// Subfolders of plugins/, sorted for deterministic load order + stable conflict messages. Hidden
|
||||
// entries (.git, .DS_Store, …) and non-directories are skipped — only folders are plugins.
|
||||
function pluginFolders(dir: string): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function asManifest(value: unknown): PluginManifest | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null;
|
||||
}
|
||||
|
||||
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
|
||||
function shapeError(manifest: PluginManifest): string | null {
|
||||
for (const field of ["nav", "permissions", "routes"] as const) {
|
||||
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function messageOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { createApp } from "./app.ts";
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { discoverPlugins } from "./discovery.ts";
|
||||
|
||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
||||
|
||||
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin (router wiring is next §2)
|
||||
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
|
||||
|
||||
const server = createApp({ cache: config.cacheTemplates }).listen(config.port, () => {
|
||||
console.log(`Listening on http://localhost:${config.port}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user