Specify the plugin contract (todo §2); typed manifest + version/conflict rules in src/plugin.ts, authoritative docs/plugin-contract.md
This commit is contained in:
85
src/plugin.test.ts
Normal file
85
src/plugin.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
checkApiVersion,
|
||||
definePlugin,
|
||||
findConflicts,
|
||||
HOST_API_VERSION,
|
||||
type Plugin,
|
||||
} from "./plugin.ts";
|
||||
|
||||
// A representative manifest exercising every field — its existence type-checks the contract
|
||||
// (handler return variants, nav fragment, permission decls, hooks). The README example.
|
||||
const scheduling: Plugin = definePlugin({
|
||||
apiVersion: HOST_API_VERSION,
|
||||
basePath: "/scheduling",
|
||||
hooks: { onBoot: () => {} },
|
||||
id: "scheduling",
|
||||
nav: [{
|
||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
||||
}],
|
||||
permissions: [{ description: "View shifts", token: "scheduling:read" }],
|
||||
routes: [
|
||||
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
|
||||
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" },
|
||||
{ handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself
|
||||
],
|
||||
});
|
||||
|
||||
test("definePlugin returns the manifest unchanged — it only types; validation is at discovery (§2)", () => {
|
||||
const m: Plugin = { apiVersion: 1, basePath: "/x", id: "x" };
|
||||
assert.equal(definePlugin(m), m); // identity, not a copy
|
||||
assert.equal(scheduling.routes?.length, 3);
|
||||
});
|
||||
|
||||
test("checkApiVersion: match ok, older warns, newer/malformed refuses (host refuses or warns, never silent)", () => {
|
||||
assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok");
|
||||
assert.equal(checkApiVersion(1, 2).level, "warn"); // plugin targets an older host
|
||||
assert.equal(checkApiVersion(HOST_API_VERSION + 1).level, "refuse"); // needs a newer host
|
||||
for (const bad of [0, -1, 1.5, "1", undefined, null, Number.NaN]) {
|
||||
assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`);
|
||||
}
|
||||
});
|
||||
|
||||
// Minimal valid plugin, overridable per case.
|
||||
const p = (over: Partial<Plugin> & Pick<Plugin, "id" | "basePath">): Plugin =>
|
||||
definePlugin({ apiVersion: HOST_API_VERSION, ...over });
|
||||
|
||||
test("findConflicts: a clean set has none", () => {
|
||||
assert.deepEqual(findConflicts([p({ basePath: "/a", id: "a" }), p({ basePath: "/b", id: "b" })]), []);
|
||||
});
|
||||
|
||||
test("findConflicts: duplicate id, overlapping basePath, and colliding route are loud errors", () => {
|
||||
const dupId = findConflicts([p({ basePath: "/a", id: "a" }), p({ basePath: "/b", id: "a" })]);
|
||||
assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error"));
|
||||
|
||||
const sameBase = findConflicts([p({ basePath: "/x", id: "a" }), p({ basePath: "/x", id: "b" })]);
|
||||
assert.ok(sameBase.some((c) => c.kind === "basePath" && c.level === "error"));
|
||||
|
||||
// A basePath that is a path-prefix of another also overlaps (routes would shadow).
|
||||
const prefix = findConflicts([p({ basePath: "/x", id: "a" }), p({ basePath: "/x/y", id: "b" })]);
|
||||
assert.ok(prefix.some((c) => c.kind === "basePath" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
|
||||
const noop = () => {};
|
||||
const dupRoute = findConflicts([p({
|
||||
basePath: "/a", id: "a",
|
||||
routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }],
|
||||
})]);
|
||||
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error"));
|
||||
});
|
||||
|
||||
test("findConflicts: duplicate nav id is an error, a shared permission token only warns", () => {
|
||||
const navDup = findConflicts([
|
||||
p({ basePath: "/a", id: "a", nav: [{ id: "dup", label: "A" }] }),
|
||||
p({ basePath: "/b", id: "b", nav: [{ id: "dup", label: "B" }] }),
|
||||
]);
|
||||
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
|
||||
// Sharing a permission across plugins is legitimate (shared role) → warn, not error.
|
||||
const permDup = findConflicts([
|
||||
p({ basePath: "/a", id: "a", permissions: [{ token: "shared:read" }] }),
|
||||
p({ basePath: "/b", id: "b", permissions: [{ token: "shared:read" }] }),
|
||||
]);
|
||||
assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
||||
});
|
||||
159
src/plugin.ts
Normal file
159
src/plugin.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// The plugin contract (todo §2) — the product's main API surface. This module is the
|
||||
// authoritative, machine-readable shape; `docs/plugin-contract.md` is the prose reference.
|
||||
// It only declares types + pure rules; the §2 discovery/router wire them to the filesystem
|
||||
// and HTTP. Philosophy: a powerful, predictable, overload-friendly API that fails loud at
|
||||
// boot/discovery rather than sandboxing at runtime.
|
||||
|
||||
import type { RequestContext } from "./context.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
|
||||
// Host contract major version. Bump on a breaking manifest/handler change; a plugin pins the
|
||||
// version it targets via `apiVersion` and the host refuses/warns on mismatch (checkApiVersion).
|
||||
export const HOST_API_VERSION = 1;
|
||||
|
||||
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||
|
||||
// A handler's return value; the host turns it into the HTTP response. Returning void is the
|
||||
// escape hatch — the handler wrote to `ctx.res` itself (streaming, custom headers, etc.).
|
||||
export type RouteResult =
|
||||
| { headers?: Record<string, string>; html: string; status?: number }
|
||||
| { headers?: Record<string, string>; json: unknown; status?: number } // for opt-in JS enhancement
|
||||
| { data?: Record<string, unknown>; headers?: Record<string, string>; status?: number; view: string }
|
||||
| { redirect: string; status?: number };
|
||||
|
||||
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
|
||||
|
||||
export interface Route {
|
||||
handler: RouteHandler;
|
||||
method: HttpMethod;
|
||||
path: string; // relative to basePath; ":name" segments become ctx.params.name
|
||||
permission?: string; // coarse gate (a role token); checked before the handler runs
|
||||
}
|
||||
|
||||
// A permission token this plugin introduces — declared for docs/seeding. Tokens are a shared
|
||||
// global namespace (so an operator grants them in Keto); namespace as `<id>:<action>`.
|
||||
export interface PermissionDecl {
|
||||
description?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
||||
export interface PluginHooks {
|
||||
onBoot?: () => Promise<void> | void; // after discovery, before the server listens
|
||||
onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit
|
||||
onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
apiVersion: number; // host contract version this plugin targets (= HOST_API_VERSION)
|
||||
basePath: string; // unique mount prefix, e.g. "/scheduling"; must not overlap another plugin's
|
||||
hooks?: PluginHooks;
|
||||
id: string; // globally unique; namespaces views, /public/<id>/, and nav/permission tokens
|
||||
nav?: NavNode[]; // fragment merged into the global menu (composeNav); ids must be globally unique
|
||||
permissions?: PermissionDecl[];
|
||||
routes?: Route[];
|
||||
}
|
||||
|
||||
// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery
|
||||
// (§2), so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
|
||||
export function definePlugin(plugin: Plugin): Plugin {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
export interface VersionCheck {
|
||||
level: "ok" | "refuse" | "warn";
|
||||
message: string;
|
||||
}
|
||||
|
||||
// The versioning rule: equal → ok; plugin older than host → warn (load, review); plugin newer
|
||||
// or not a positive integer → refuse. Discovery maps refuse→throw, warn→log.
|
||||
export function checkApiVersion(pluginVersion: unknown, hostVersion: number = HOST_API_VERSION): VersionCheck {
|
||||
if (typeof pluginVersion !== "number" || !Number.isInteger(pluginVersion) || pluginVersion < 1) {
|
||||
return { level: "refuse", message: `apiVersion must be a positive integer; got ${JSON.stringify(pluginVersion)}` };
|
||||
}
|
||||
if (pluginVersion > hostVersion) {
|
||||
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` };
|
||||
}
|
||||
if (pluginVersion < hostVersion) {
|
||||
return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — review for deprecated behaviour` };
|
||||
}
|
||||
return { level: "ok", message: `apiVersion ${pluginVersion}` };
|
||||
}
|
||||
|
||||
export interface PluginConflict {
|
||||
kind: "basePath" | "id" | "nav-id" | "permission" | "route";
|
||||
level: "error" | "warn";
|
||||
message: string;
|
||||
plugins: string[]; // unique ids involved
|
||||
}
|
||||
|
||||
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
|
||||
// manifests; discovery throws on any "error" and logs every "warn". Shared permission tokens are
|
||||
// the one intentional overlap, so they warn rather than error.
|
||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
const out: PluginConflict[] = [];
|
||||
|
||||
const idCounts = new Map<string, number>();
|
||||
for (const plugin of plugins) idCounts.set(plugin.id, (idCounts.get(plugin.id) ?? 0) + 1);
|
||||
for (const [id, n] of idCounts) {
|
||||
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
|
||||
}
|
||||
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
for (let j = i + 1; j < plugins.length; j++) {
|
||||
const a = plugins[i] as Plugin;
|
||||
const b = plugins[j] as Plugin;
|
||||
if (basePathOverlap(a.basePath, b.basePath)) {
|
||||
out.push({ kind: "basePath", level: "error", message: `basePath "${a.basePath}" (${a.id}) overlaps "${b.basePath}" (${b.id})`, plugins: uniq([a.id, b.id]) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(plugins, (plugin, push) => {
|
||||
for (const route of plugin.routes ?? []) push(`${route.method} ${joinPath(plugin.basePath, route.path)}`);
|
||||
}).forEach((owners, key) => {
|
||||
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
collect(plugins, (plugin, push) => collectNavIds(plugin.nav, push)).forEach((owners, id) => {
|
||||
if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
collect(plugins, (plugin, push) => {
|
||||
for (const decl of plugin.permissions ?? []) push(decl.token);
|
||||
}).forEach((owners, token) => {
|
||||
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${token}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count).
|
||||
function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map<string, string[]> {
|
||||
const owners = new Map<string, string[]>();
|
||||
for (const plugin of plugins) emit(plugin, (key) => owners.set(key, [...(owners.get(key) ?? []), plugin.id]));
|
||||
return owners;
|
||||
}
|
||||
|
||||
function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void): void {
|
||||
for (const node of nodes ?? []) {
|
||||
if (node.id != null) push(node.id);
|
||||
collectNavIds(node.children, push);
|
||||
}
|
||||
}
|
||||
|
||||
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
||||
|
||||
function basePathOverlap(a: string, b: string): boolean {
|
||||
const x = trimSlash(a);
|
||||
const y = trimSlash(b);
|
||||
return x === y || y.startsWith(`${x}/`) || x.startsWith(`${y}/`);
|
||||
}
|
||||
|
||||
function joinPath(basePath: string, path: string): string {
|
||||
return `${trimSlash(basePath)}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
function uniq(xs: string[]): string[] {
|
||||
return [...new Set(xs)];
|
||||
}
|
||||
Reference in New Issue
Block a user