Files
skills/plugins/library-navigator/skills/api-stack/api-plugins/sp-api-plugin-kit/SKILL.md
2026-06-09 21:06:27 +01:00

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:

  1. Plugin-scoped (router.use(mw)) — runs for every route THIS plugin registers
  2. Route-local (config.middleware) — runs only for that specific route
  3. Global (globalMiddleware in 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-plugin scaffolder
  • The api-plugin authoring 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 createApi runtime — no mocks
  • RecordingLogger captures all log output for assertions
  • InMemoryModuleResolver enables blue-green reload testing
  • examplePlugin and examplePluginV2 serve as living documentation

  • @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