Let a plugin carry its own package.json and npm dependencies
CI / full-gate (push) Successful in 2m53s
CI / full-gate (push) Successful in 2m53s
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user