Let a plugin carry its own package.json and npm dependencies
CI / full-gate (push) Successful in 2m53s

This commit is contained in:
lilleman
2026-08-17 22:23:53 +02:00
parent 1ba6dbdc51
commit fee4fe632b
30 changed files with 173 additions and 66 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ export interface RequestContext {
// on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
// wraps the hrefs it builds itself.
localeHref(href: string): string;
// Every installed locale, sorted. With `localeLabel` (from #plugin-api) it is what a plugin needs
// Every installed locale, sorted. With `localeLabel` (from the barrel) it is what a plugin needs
// to build its own language picker; the host's own picker is already in the shell.
locales: string[];
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
+20
View File
@@ -57,6 +57,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "1.0.0", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
{ name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s },
{ name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
];
@@ -102,6 +104,24 @@ test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard)
assert.equal(typeof plugins[0]?.dashboard, "function");
});
// The barrel still resolves from a folder holding its own package.json because host deps sit at
// /node_modules, above every plugin scope (README → Plugin dependencies).
test("a plugin may carry its own package.json, node_modules and dependencies", async (t) => {
const dir = scaffold(t, {
"shop/package.json": `{ "name": "shop", "version": "0.0.0", "type": "module", "dependencies": { "price-tag": "1.0.0" } }`,
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
`export default definePlugin({ apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
"node_modules/hoisted/index.js": `export default 1;`,
});
const plugins = await discoverPlugins({ dir });
assert.deepEqual(plugins.map((p) => p.id), ["shop"]); // node_modules is not a plugin folder
assert.deepEqual(await plugins[0]?.routes?.[0]?.handler(null as never), { html: "20 kr" });
});
test("a shared permission name only warns — both plugins still load", async (t) => {
const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
+23 -3
View File
@@ -4,7 +4,7 @@
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
import { existsSync, readdirSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
@@ -37,6 +37,8 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
if (RESERVED_PLUGIN_IDS.has(id)) { fail(`"${id}" is a reserved id — it would shadow a built-in host route`); continue; }
const file = join(dir, id, "plugin.ts");
if (!existsSync(file)) { fail("no plugin.ts found"); continue; }
const packaging = packagingError(join(dir, id));
if (packaging) { fail(packaging); continue; }
let mod: { default?: unknown };
try {
@@ -78,14 +80,32 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
}
// 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.
// entries (.git, .DS_Store, …) and non-directories are skipped — only folders are plugins. So is
// node_modules, which npm leaves here when a dependency install is pointed at plugins/ itself.
function pluginFolders(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules")
.map((e) => e.name)
.sort();
}
// Without a `type`, which npm never writes, the plugin's own package.json leaves its folder
// CommonJS: a .js helper breaks outright and every .ts costs a re-parse.
function packagingError(folder: string): string | null {
const file = join(folder, "package.json");
if (!existsSync(file)) return null;
let manifest: { type?: unknown };
try {
manifest = JSON.parse(readFileSync(file, "utf8")) as { type?: unknown };
} catch (err) {
return `package.json is not valid JSON — ${messageOf(err)}`;
}
return manifest.type === "module"
? null
: `package.json must set "type": "module" — npm writes no type, which leaves the folder CommonJS`;
}
function asManifest(value: unknown): PluginManifest | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null;
}
+9
View File
@@ -5,6 +5,15 @@ import assert from "node:assert/strict";
import test from "node:test";
import * as api from "./plugin-api.ts";
// A plugin with its own package.json reaches the barrel only as a package; a second copy landing
// there would fail every `instanceof GuardError` a handler makes.
test("the barrel resolves by package name to this same module", async () => {
const asPackage = await import("@plainpages/plugin-api");
assert.equal(asPackage.GuardError, api.GuardError);
assert.equal(asPackage.definePlugin, api.definePlugin);
});
test("plugin-api re-exports the stable author value surface", () => {
for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD", "tracedFetch", "Log", "safeUrl"]) {
assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`);
+1 -1
View File
@@ -1,6 +1,6 @@
// System capabilities: privileged host services a first-party/system plugin (the built-in admin
// screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin
// clients and the instant-revoke hook. Exposed on ctx.system and re-exported via #plugin-api.
// clients and the instant-revoke hook. Exposed on ctx.system and re-exported via @plainpages/plugin-api.
//
// Every field is optional: it is present only when the host wired that dependency (Ory configured,
// denylist enabled), and ctx.system itself is undefined when the host wired none. A plugin must