9.2 KiB
sp-api-plugin-kit
One-liner
Plugin-authoring toolkit for the Suaveplan API platform — provides definePlugin (a typed authoring factory that normalises a friendly spec into a @suaveplan/api Plugin and narrows ctx.capabilities to the declared requires set), an in-memory test harness (mountPlugin/createTestApp) that serves plugins through the real createApi runtime with start/stop/reload helpers and no mocks, and a canonical reference example plugin proving the full author-to-runtime-to-serve-to-reload contributor loop.
Architecture
| Layer | Key components | Role |
|---|---|---|
| definePlugin | definePlugin(spec) |
Authoring factory — turns a DefinePluginSpec into a host Plugin |
| KitRouter | RecordingRouter |
In-memory router that records RouteDescriptor[] per verb; composes plugin-scoped + route-local middleware |
| Harness | mountPlugin, createTestApp |
Stand up an in-memory app over the real createApi runtime; no mocks |
| Example | examplePlugin, examplePluginV2 |
Canonical reference plugins proving the full surface end-to-end |
Public API surface
definePlugin
import { definePlugin } from "@suaveplan/api-plugin-kit";
const greeter = definePlugin({
id: "greeter",
version: "1.0.0",
requires: ["logger"] as const,
routes(router) {
router.get("/hello/:name", (ctx) => {
ctx.capabilities.logger.info("greet", { name: ctx.params.name });
return json({ message: `hello, ${ctx.params.name}` });
});
},
});
DefinePluginSpec
interface DefinePluginSpec<Caps extends KitCapabilities = KitCapabilities> {
id: string; // manifest.name
version: string; // manifest.version
displayName?: string;
description?: string;
dependsOn?: readonly string[]; // topo-sort ordering
requires?: Caps; // capability ids; narrows handler context
permissions?: PermissionsManifest;
isolation?: "trusted" | "isolated" | "sandboxed";
trustLevel?: "first-party" | "third-party" | "untrusted";
apiHost?: string; // semver range (default "^0.1.0")
globalMiddleware?: readonly Middleware<Caps>[]; // pipeline-wide
routes(router: KitRouter<Caps>): void;
start?(ctx: PluginContext<Caps>): Promise<void> | void;
stop?(ctx: PluginContext<Caps>): Promise<void> | void;
}
KitRouter
interface KitRouter<Caps extends KitCapabilities> {
get(path, handler, config?): void;
post(path, handler, config?): void;
put(path, handler, config?): void;
patch(path, handler, config?): void;
delete(path, handler, config?): void;
options(path, handler, config?): void;
head(path, handler, config?): void;
use(middleware: Middleware<Caps>): void; // plugin-scoped
descriptors(): readonly RouteDescriptor<Caps>[];
}
Per-route config (KitRouteConfig) carries RouteMeta + optional middleware (route-local):
router.get("/items/:id", handler, {
operationId: "items.get",
summary: "Get an item",
request: { params: IdParamSchema },
responses: { "200": { body: ItemSchema } },
middleware: [requireAuth()],
});
Test harness
import { mountPlugin, createTestApp } from "@suaveplan/api-plugin-kit";
// Single plugin
const app = await mountPlugin(greeter);
const res = await app.fetch("/hello/ada");
console.log(await res.json()); // { message: "hello, ada" }
await app.dispose();
// Multiple plugins with custom capabilities
const app = await createTestApp([pluginA, pluginB], {
capabilities: [{ id: "logger", factory: () => logger }],
autoStart: true,
});
// Reload a plugin (blue-green via InMemoryModuleResolver)
await app.reload("greeter", nextVersionPlugin);
// Lifecycle helpers
await app.start("plugin-id");
await app.stop("plugin-id");
await app.dispose();
Subpath exports
| Export path | Exports |
|---|---|
. |
definePlugin, mountPlugin, createTestApp, examplePlugin, types |
./define-plugin |
definePlugin, KitRouter, KitRouteConfig, DefinePluginSpec, KitCapabilities |
./harness |
mountPlugin, createTestApp, TestAppHandle, RecordingLogger |
./example |
examplePlugin, examplePluginV2 |
Integration patterns
Capability narrowing
requires: ["logger"] as const narrows ctx.capabilities so handlers see only the declared capabilities:
definePlugin({
id: "audit",
requires: ["logger", "db"] as const,
routes(router) {
// ctx.capabilities is typed as { logger: Logger; db: Database }
router.post("/audit", (ctx) => {
ctx.capabilities.logger.info("audit", { event: "login" });
ctx.capabilities.db.query("INSERT audit ...");
});
},
});
Plugin-scoped vs route-local vs global middleware
Three middleware scopes:
- Plugin-scoped (
router.use(mw)) — runs for every route THIS plugin registers - Route-local (
config.middleware) — runs only for that specific route - Global (
globalMiddlewarein spec) — runs for EVERY route in the ENTIRE app (cross-cutting concerns like rate-limit, CORS, audit)
RecordingRouter middleware composition
From src/define-plugin/define-plugin.ts:
class RecordingRouter<Caps> implements KitRouter<Caps> {
private readonly recorded: RouteDescriptor<Caps>[] = [];
private readonly scoped: Middleware<Caps>[] = [];
public use(middleware: Middleware<Caps>): void {
this.scoped.push(middleware);
}
public descriptors(): readonly RouteDescriptor<Caps>[] {
return this.recorded.map((descriptor) => {
const local = descriptor.middleware ?? [];
const middleware = [...this.scoped, ...local];
return middleware.length === 0
? descriptor
: { ...descriptor, middleware };
});
}
}
Code patterns
definePlugin implementation
From src/define-plugin/define-plugin.ts:
export function definePlugin<Caps extends KitCapabilities = KitCapabilities>(
spec: DefinePluginSpec<Caps>
): Plugin {
const manifest = buildManifest(spec);
const base: Plugin = {
manifest,
register(reg: RouteRegistrar): void {
const router = new RecordingRouter<Caps>();
spec.routes(router);
for (const descriptor of router.descriptors()) {
reg.addRoute(descriptor as RouteDescriptor);
}
},
};
const plugin: Plugin =
spec.globalMiddleware === undefined
? base
: { ...base, globalMiddleware: spec.globalMiddleware as readonly Middleware[] };
if (spec.start !== undefined) {
const start = spec.start;
return Object.assign(plugin, {
start: (ctx: PluginContext) => start(ctx as PluginContext<Caps>),
...(spec.stop === undefined ? {} : { stop: assignStop(spec.stop) }),
});
}
if (spec.stop !== undefined) {
return Object.assign(plugin, { stop: assignStop(spec.stop) });
}
return plugin;
}
Test harness — createTestApp
From src/harness/harness.ts:
export async function createTestApp(
plugins: readonly Plugin[],
options: TestAppOptions = {}
): Promise<TestAppHandle> {
const records: LogRecord[] = [];
const logger = createRecordingLogger(records);
const capabilities = options.capabilities ?? [{ id: "logger", factory: () => logger }];
const resolver = new InMemoryModuleResolver();
const runtime = createApi({ plugins, capabilities, moduleResolver: resolver });
if (options.autoStart !== false) {
for (const plugin of plugins) {
await runtime.start(plugin.manifest.name);
}
}
return {
runtime,
logger,
async fetch(input, init): Promise<Response> {
return runtime.fetch(toRequest(input, init));
},
async reload(pluginName, nextPlugin): Promise<void> {
const specifier = `kit:reload/${pluginName}`;
resolver.set(specifier, nextPlugin);
await runtime.reload(pluginName, specifier);
},
async dispose(): Promise<void> {
// stop all started plugins
},
};
}
Design philosophy
api-plugin-kit implements the Authoring row of the API platform redesign. It supersedes:
- The former
create-suaveplan-pluginscaffolder - The
api-pluginauthoring surface - The old
api-plugin-example
Every other api-plugin-* package in the monorepo is authored with definePlugin from this kit. The kit is not a plugin itself — it is the toolchain used to build plugins.
Testing notes
- The harness runs plugins through the real
createApiruntime — no mocks RecordingLoggercaptures all log output for assertionsInMemoryModuleResolverenables blue-green reload testingexamplePluginandexamplePluginV2serve as living documentation
Related packages
@suaveplan/api— the host runtime (createApi,Plugin,RouteRegistrar)@suaveplan/api-types—RouteDescriptor,RouteMeta,Middleware,PluginContext@suaveplan/api-core—json()response helper- Every
api-plugin-*package — all authored with this kit