inkan

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 request

decorate 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 promise

With 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 | undefined

Type 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 answers

Prefixes 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.

On this page