Writing a plugin
Shared and scoped plugins, decorations, route meta, OpenAPI and hook order.
A plugin is a function that gets a scope and its options. Everything a plugin can do is the
API of the app: routes, hooks, decorations, onOperation. This page is about what matters when
the plugin is for other people. The hooks page has the basics.
New in 0.8.0
register(plugin, options, { prefix }), { order: "last" } on every hook, around(),
stateKey for typed slots in ctx.state, joinPath, and cors as a shared plugin. From
0.8.0 the stable API is frozen: see Stability for what a plugin can rely
on, and declare inkan: ">=0.8.0 <1.0.0".
Shared or scoped
import { } from "@vxnsin/inkan";
const = (() => { // scoped: a scope of its own
.(requireAdmin);
.("/stats", () => stats());
});
const = (() => { // shared: acts on the scope around it
.((, ) => void (.["x-powered-by"] = "tea"));
}, { : true, : "powered-by" });
.(, {}, { : "/admin" });
.();A scoped plugin gets a scope inside the one it is registered in. Its hooks and decorations reach its own routes and nothing outside. Right for a plugin that brings routes: an admin area, a webhook receiver.
A shared plugin works in the scope it is registered in. Registered on the app, its hooks
run for every route; registered inside another plugin, for that plugin's routes. Right for
a plugin whose point is to act on the routes around it: a rate limit, sessions, headers.
rateLimit, compress and every package under More middleware
are shared.
dev and prefix
const = (() => {
if (!.dev) return; // nothing in production
.("/_debug", () => ({ : .prefix || "/" }));
});Every scope says, read-only, whether the app runs in development (scope.dev, the app's
dev option) and under which path prefix it was registered (scope.prefix: "" for the
app, "/admin" for register(plugin, options, { prefix: "/admin" })). A plugin that sets a
cookie path or builds a link from its own routes reads prefix instead of asking for it
again, and puts paths together with joinPath(scope.prefix, "/login"), which leaves one
slash between the parts.
The second argument of register is what the plugin is handed, the third where it is
mounted: a plugin's options never need a prefix of their own.
decorate or decorateRequest
.("db", pool); // one value, for every request
.decorateRequest("user", () => sessions.get(.cookies.sid)); // made per requestdecorate puts one value on the context's prototype: it costs nothing per request. Use it
for things every request shares: a pool, a client, a config.
decorateRequest puts a getter there instead. init runs the first time a request reads
ctx.user, and the value is kept on that context, so it runs at most once per request, and
never for a request that does not ask. Hooks see it too, and setting ctx.user = … in a hook
wins over init. init can read other decorations. Both refuse a name the context already
has, so a plugin cannot hide ctx.params or another plugin's decoration.
Async decorations
An init that returns a promise makes ctx.user that promise, typed Promise<User>: made
once however often it is read, never for a request that does not read it, and a problem it
throws is the answer.
.("user", async () => {
const = await sessions.load(..);
if (!) throw (401, "unauthorized");
return ;
}, { : "preHandler", : () => .. > 0 });
.("/me", { : { : "sid", : "cookie" } }, ({ }) => .name); // the value, not a promiseWith resolve: "preHandler" the promise is awaited after the input is checked and before the
preHandler hooks, so they and the handler see the value itself, typed without the promise.
That loads it for every request of the routes it covers, read or not, so when picks them,
asked once per route with its ctx.route view. Reading it on a route when leaves out
throws, instead of handing over a promise typed as the value; onRequest hooks, which run
before it is awaited, still get the promise. Apps that do not use it pay nothing for it.
Typed decorations out of shared plugins
A shared plugin that returns its scope hands on the types of what it decorated. After
register, the handlers see them:
type = { : number; : string[] };
const = (
(, : { : string }) =>
.decorateRequest("user", (): | undefined => {
const = .headers[.];
return ? { : (), : ["admin"] } : ;
}),
{ : true, : "auth" },
);
()
.(, { : "x-user" })
.("/me", () => ({ : .user?.id ?? null })); // ctx.user: User | undefinedType the options on the parameter (o: { header: string }), not as a type argument, or
there is nothing left to infer from. An async plugin works the same: return the scope at
the end. A scoped plugin keeps its decorations to itself, in the types too.
For a plugin written in JavaScript, or one that does not return its scope, say what it adds
in the type: Plugin<Options, Needs, Adds>.
import type { } from "@vxnsin/inkan";
export declare function (: { : string }): <{}, {}, { : }>;Routes out of plugins, in the typed client
A plugin made with plugin(), shared or not, that returns its scope hands on the routes it
defined in that chain. After register, the typed client knows them, under
the prefix they were registered with:
const = (() =>
.("/login", { : .({ : .(), : .() }), : { 200: .({ : .() }) } }, login)
.("/logout", {}, logout),
);
export const = ().(, {}, { : "/auth" }).("/teas", listTeas);
// web.ts
const = client<typeof >("https://shop.example");
await .post("/auth/login", { : { : "ana", } }); // typed, body and answersPrefixes add up through plugins that register others. A plain function passed to register
hands no types on; neither do routes added one statement at a time. For a plugin written in
JavaScript, the fourth type parameter says which routes it brings:
Plugin<Options, Needs, Adds, Routes>.
ctx.route: security and meta
Every hook sees the route that matched as ctx.route: its method, its path as written
(/teas/:id), the security it asks for (its own or its group's; empty for none), and its
meta. The object is made once per route, before the first request.
meta is free-form, for plugins to read:
.("/reports", { : "bearer", : { : { : ["admin"] } } }, list);
.(() => {
const = .?.meta.auth?.roles ?? [];
if (.length && !.some(() => .user?.roles.includes())) throw (403, "forbidden");
});A plugin names what it reads by extending the exported RouteMeta interface. Then a wrong
shape does not compile, and the editor suggests the field:
declare module "@vxnsin/inkan" {
interface RouteMeta {
?: { : string[] };
}
}RouteMeta takes symbol keys as well, so a plugin can keep a key to itself: one only it can
write, which no route spec can set by accident.
export const = ("my-plugin");
declare module "@vxnsin/inkan" {
interface RouteMeta {
[]?: boolean;
}
}A hook that needs a header no route lists reads ctx.rawHeaders: ctx.headers is cut down
to a route's header schema, rawHeaders is the request's as it arrived (read it, do not
change it). ctx.secure and ctx.protocol say whether the request came over TLS, the
proxies' word included under trustProxy.
onOperation(): adding to OpenAPI
A plugin that adds a header or an answer should say so in the document. onOperation runs for
every route of its scope when the document is written, never per request:
.((, , ) => {
if (!.?.("bearer")) return;
.!.bearer!.bearerFormat = "JWT";
.responses["403"] ??= { : "The token does not allow this" };
(.parameters ??= []).push({ : "x-tenant", : "header", : false, : { : "string" } });
});op is the OpenAPI operation, components the document's components: securitySchemes
already holds the schemes the routes ask for, and new sections (responses, parameters)
are kept. route is an OperationRoute: the frozen ctx.route view (method, path,
security, meta) plus the spec the route was written with, read-only; change the
operation instead. Outer scopes' hooks run first. A hook added after the document was
written still reaches it: it is written again.
The fourth argument, ref(schema, name?), puts a t.* schema into the document once, under
components.schemas, and returns { $ref } to it, as a route's named schemas are. An
unnamed schema needs name:
import { } from "@vxnsin/inkan";
const = .({ : .() });
.((, , , ) => {
if (!..) return;
.responses["423"] ??= {
: "Locked",
: { "application/json": { : (, "Locked") } },
};
});Hook order: order: "last"
Hooks run outermost scope first, in the order they were added. A hook that has to come after
all the others of its kind says so with { order: "last" }, which every hook takes. An
onSend hook that changes the bytes of the body is the usual case:
.((, ) => sign(), { : "last" });Last hooks run after every other hook of their kind on the route, again outermost first.
compress is one, so a hook that reads the body (an ETag, a
signature) sees it before it is packed, wherever either was registered.
around(): wrapping the handler
const = (() => {
.(async (, ) => {
const = .();
await ();
.("server-timing", `app;dur=${(.() - ).(1)}`);
});
}, { : true, : "timing" });around(fn) wraps the handler of every route of the scope and the scopes inside it, those
defined before the call too, where a route's own middleware runs: after the input is checked
and the preHandler hooks. await next() runs the rest. Outer scopes' around wrap inner
ones', and each is joined into the route once, before the first request. Right for timing,
a transaction around the handler, or an AsyncLocalStorage of your own. Experimental.
stateKey: a slot of your own in ctx.state
import { } from "@vxnsin/inkan";
export const = <>("tenant");
const = (() => {
.(() => .(, lookUp(..host)));
}, { : true, : "tenants" });
.("/me", () => .()?.name);A string key in ctx.state is anybody's: two plugins that pick "tenant" overwrite each
other. stateKey<T>(name) keeps the value under a symbol of its own, typed, and the plugin
exports the key to whoever should read it. Experimental.
The pages inkan serves itself
/docs, /openapi.json and /_inkan are answered before any hook runs, so no plugin's
hooks (secure headers, auth, a rate limit) apply to them. To put hooks on one, turn it off
(docs: false, openapi: false, inspector: false) and serve it from a route of your own.
Behind a proxy
ctx.ip is the socket's address unless the app trusts its proxies. A plugin that keys on
the client, like rateLimit, reads ctx.ip and gets the right one once the app says
trustProxy (see App options). Do not read x-forwarded-for
yourself: anyone can send it.
Cookies outside a context
parseCookies(header) and serializeCookie(name, value, options) are exported for plugins
that read or write cookies on their own, with the same defaults as ctx.setCookie
(Path=/, HttpOnly, SameSite=Lax).
Publishing it
Name it inkan-<name>, keep inkan a peer dependency, and add it to the list: see
Publish a plugin. Everything already out there, official and
community, is on Hanko.