Skip to content

Writing a hook package

This guide writes a reusable hook the way the framework’s own packages are written, using an API-key check as the example: the package’s shape, the rules that keep its types and failures honest, and a test.

Tetsu has no plugin system. A package is a function that takes options and returns one hook, which the application mounts like any hook of its own — cors(), requestId() and rateLimit() all have this shape. The compiler checks the package’s hook as it checks any other: its slot, what it needs from the context and what it adds.

The function runs once, where the application is wired, and reads and checks the options. The hook it returns runs for every request.

A package that seems to need two slots usually does not: accessLog() reads the start time from ctx.startedAt instead of a beforeParse hook, and cors() sets its headers on ctx.out instead of a late hook. If yours still needs two, open an issue.

The hook reads a key from a header, looks it up, refuses the request when the key is unknown, and adds the key’s client to the context:

import { hook, httpError, reportFailure } from "@tetsujs/core";
import { secured } from "@tetsujs/openapi";
export interface ApiClient {
readonly
id: string
id
: string;
readonly
name: string
name
: string;
}
export interface ApiKeyStore {
find(
keyHash: string
keyHash
: string): ApiClient | undefined | Promise<ApiClient | undefined>;
used?(
clientId: string
clientId
: string,
at: Date
at
: Date): Promise<void>;
}
export interface ApiKeyOptions {
readonly
store: ApiKeyStore
store
: ApiKeyStore;
readonly
header?: string | undefined
header
?: string;
}
export function apiKey(
options: ApiKeyOptions
options
: ApiKeyOptions) {
const
const header: string
header
=
options: ApiKeyOptions
options
.
header?: string | undefined
header
?? "x-api-key";
const check = hook.beforeParse(async (ctx) => {
const
const key: string | null
key
= ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
headers: Headers

The headers read-only property of the with the request.

MDN Reference

headers
.get(
const header: string
header
);
const
const client: ApiClient | undefined
client
=
const key: string | null
key
? await
options: ApiKeyOptions
options
.
store: ApiKeyStore
store
.find(hashOf(
const key: string
key
)) : undefined;
if (!
const client: ApiClient | undefined
client
) throw httpError(401, "INVALID_API_KEY", "A valid API key is required");
void
options: ApiKeyOptions
options
.
store: ApiKeyStore
store
.used?.(
const client: ApiClient
client
.
id: string
id
, new Date()).catch((
error: any
error
) => {
reportFailure(ctx, "apiKey",
error: any
error
);
});
return {
client: ApiClient
client
};
});
return secured(check, {
name: string

The name the scheme is registered under in components.

name
: "apiKey",
scheme: SecurityScheme

The scheme itself, registered once per name.

scheme
: {
type: "apiKey" | "http" | "oauth2" | "openIdConnect" | "mutualTLS"
type
: "apiKey",
in: string
in
: "header",
name: string
name
:
const header: string
header
},
error?: string | undefined

The error code the refusal carries, when the guard answers with the framework's envelope — "UNAUTHORIZED", "SESSION_EXPIRED".

Documented as a const for the same reason as on a

DocumentedResponse

: only the guard knows its own code.

error
: "INVALID_API_KEY",
description?: string | undefined

How the refusal is described in the document.

description
: "The API key is missing or unknown",
});
}
export type ApiKeyHook = ReturnType<typeof apiKey>;
function hashOf(
key: string
key
: string): string {
return new Bun.CryptoHasher("sha256").update(
key: string
key
).digest("hex");
}

Mounted on a route, it gives the handler a typed ctx.client:

import { controller, route } from "@tetsujs/core";
const guard = apiKey({
store: {
find(hash: string): ApiClient | undefined;
}
store
: { find: (
hash: string
hash
) =>
const clients: Map<string, ApiClient>
clients
.get(
hash: string
hash
) } });
const reportsController = controller("Reports", () => ({
list: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/reports"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/reports",
hooks: { beforeParse: [guard] },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
client: ApiClient;
}) => {
requestedBy: string;
}

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
client: ApiClient;
}
ctx
) => ({
requestedBy: string
requestedBy
:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
client: ApiClient;
}
ctx
.client.
name: string
name
}),
client: ApiClient
}),
}));

The sections below explain each part.

apiKey has no return type on purpose. The hook’s type carries its slot, the context it needs and the context it adds, all inferred from the hook.beforeParse call. An annotation such as AnyHook erases them, and even a precise slot loses what the hook adds:

import type { BaseCtx, Hook } from "@tetsujs/core";
import { hook, httpError, route } from "@tetsujs/core";
function apiKey(): Hook<"beforeParse", BaseCtx, unknown> {
return hook.beforeParse((ctx) => {
if (!ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
headers: Headers

The headers read-only property of the with the request.

MDN Reference

headers
.has("x-api-key")) throw httpError(401, "INVALID_API_KEY");
return {
client: {
id: string;
name: string;
}
client
: {
id: string
id
: "c1",
name: string
name
: "Reports" } };
});
}
route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/reports"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/reports",
hooks: { beforeParse: [apiKey()] },
handler: any

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
) =>
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.client.name,
Error ts(2339) ― Property 'client' does not exist on type '{ readonly req: Request & { readonly cookies?: CookieMap | undefined; }; readonly server: Server<unknown>; readonly out: Outgoing; readonly route: RouteInfo; readonly startedAt: number; readonly params: {}; }'.
});

To give the type a public name, read it off the function: export type ApiKeyHook = ReturnType<typeof apiKey>.

The one exception is a package whose slot is chosen by an option, as rateLimit()’s slot is. It writes one overload per slot, each returning that slot’s precise hook. See the rate limiter’s source.

Refuse by throwing, set headers on ctx.out

Section titled “Refuse by throwing, set headers on ctx.out”

A refusal is a thrown HttpError made with httpError(status, code, message), never a Response the hook builds. A thrown error goes through the application’s onError hooks, so an application with its own error format formats the package’s refusal too. Name the code, such as INVALID_API_KEY, after what happened; it is what clients branch on. See Errors.

A header the package adds goes on ctx.out.headers. The core puts those on every response that leaves — the handler’s, an error, a 404 — so a header set before a refusal is on the refusal too. A maintenance switch shows both rules, and how to document a refusal that carries a header:

import { hook, httpError } from "@tetsujs/core";
import { documented } from "@tetsujs/openapi";
export function maintenance(
options: {
readonly until: () => Date | undefined;
}
options
: { readonly
until: () => Date | undefined
until
: () => Date | undefined }) {
const check = hook.beforeParse((ctx) => {
const
const end: Date | undefined
end
=
options: {
readonly until: () => Date | undefined;
}
options
.
until: () => Date | undefined
until
();
if (!
const end: Date | undefined
end
) return;
ctx.
out: Outgoing

Response parameters for the serialized handler result.

out
.
headers: Headers

Headers applied to the outgoing response, whatever produced it — set-cookie values are appended, any other name overwrites.

A live standard Headers, created on first access. It is never assigned, only mutated — set() to own a header, append() to add to it — so two hooks writing headers compose instead of overwriting each other's whole set.

headers
.set("retry-after", String(Math.max(1, Math.ceil((
const end: Date
end
.getTime() - Date.now()) / 1000))));
throw httpError(503, "MAINTENANCE", "Down for maintenance");
});
return documented(check, {
responses?: readonly DocumentedResponse[] | undefined

What the hook can answer with.

What the handler answers with, as the route's own responses: a status the route's response map declares too is described by both, and a route that says nothing gets no placeholder 200 in their place.

responses
: [
{
status: number
status
: 503,
description: string
description
: "The API is down for maintenance",
error?: string | undefined

The error code of the failure envelope, when the hook answers with the framework's shape — "RATE_LIMITED", "SESSION_EXPIRED".

Documented as a const, so a client can discriminate on it. Only the hook knows its own code; without one the document says the field is a string and no more.

error
: "MAINTENANCE",
headers?: Readonly<Record<string, DocumentedHeader>> | undefined

Headers the hook sets on this response, by name — the retry-after of a refusal.

Documented as possible rather than required: a status several sources answer with is one response in the document, and a header one of them sets is not on the others'.

headers
: { "retry-after": {
schema: JsonSchema
schema
: {
type?: JsonSchemaType | readonly JsonSchemaType[] | undefined
type
: "integer",
minimum?: number | undefined
minimum
: 1 } } },
},
],
});
}

A hook that answers by itself — a 401, a 429, a 503 — changes what the routes it guards can answer. The package annotates its hook with @tetsujs/openapi so the OpenAPI document says so on every route it runs for:

  • secured(hook, requirement) adds a security scheme and its refusal, 401 unless the requirement sets status.
  • documented(hook, { responses }) adds responses: a status, its error code, and the fields and headers the hook adds.

Both return a copy of the hook with the same type, so the annotated hook mounts exactly like the plain one. See Documenting hooks.

Anything the hook keeps between requests belongs to the instance apiKey() returned: two calls make two independent hooks. Keep nothing in module scope, where every application and every test in the process would share it.

State shared across processes — counters, locks, the keys themselves — goes behind an interface the application implements, as ApiKeyStore does here and RateLimitStore does for @tetsujs/rate-limit. Keep the interface to the methods the hook calls, and let a method return a value or a promise, so an in-memory test store can stay synchronous.

The store is given the key’s hash, not the key, so a database dump holds no working keys.

Recording when a key was last used should not make the request wait, so the hook starts the write and does not await it. If the write fails, the response may already be gone, and there is nobody to answer with an error. reportFailure(ctx, source, error) hands the failure to the application’s reportError with the request’s context, so the report carries the request id. source names the package.

A failure the hook can answer is thrown instead: a store that throws in find becomes a 500 and is reported like any unhandled error.

A package does not start an interval or a timeout that outlives the request: the timer keeps the process alive, nobody can stop it, and tests hang. To expire entries, sweep as the hook is used, the way the rate limiter’s memory store drops old windows as its map grows. For work in the background, take a signal from the application as an option, such as the stopping signal of @tetsujs/lifecycle.

Test a package the way you test an application: mount it on a small application, serve it with serve(), and send it requests. The same test can check that the document describes the refusal the hook really sends:

import { expect, test } from "bun:test";
import { controller, createApp, route } from "@tetsujs/core";
import { serve } from "@tetsujs/core/testing";
import { openapi } from "@tetsujs/openapi";
import { assertDescribed } from "@tetsujs/openapi/testing";
import { apiKey } from "./api-key";
const
const hashOf: (key: string) => string
hashOf
= (
key: string
key
: string) => new Bun.CryptoHasher("sha256").update(
key: string
key
).digest("hex");
const
const clients: Map<string, {
id: string;
name: string;
}>
clients
= new Map([[
const hashOf: (key: string) => string
hashOf
("key-1"), {
id: string
id
: "c1",
name: string
name
: "Reports" }]]);
const guard = apiKey({
store: ApiKeyStore
store
: { find: (
hash: string
hash
) =>
const clients: Map<string, {
id: string;
name: string;
}>
clients
.get(
hash: string
hash
) } });
const reports = controller("Reports", () => ({
list: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/reports"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/reports",
hooks: { beforeParse: [guard] },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
client: ApiClient;
}) => {
requestedBy: string;
}

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
client: ApiClient;
}
ctx
) => ({
requestedBy: string
requestedBy
:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
client: ApiClient;
}
ctx
.
client: ApiClient
client
.
name: string
name
}),
}),
}));
const app = createApp({ routes: reports() });
const
const request: RequestFn
request
= serve(app);
const {
const document: OpenApiDocument
document
} = openapi(app, {
info: DocumentInfo

Title, version and the rest of the document's info block.

info
: {
title: string
title
: "Test",
version: string
version
: "1" } });
test("a known key passes, with its client", async () => {
const
const res: Response
res
= await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/reports", {
headers?: HeadersInit | undefined

A Headers object, an object literal, or an array of two-item arrays to set request's headers.

headers
: { "x-api-key": "key-1" } });
expect(await
const res: Response
res
.json()).toEqual({
requestedBy: string
requestedBy
: "Reports" });
});
test("an unknown key is refused as the document says", async () => {
const
const res: Response
res
= await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/reports", {
headers?: HeadersInit | undefined

A Headers object, an object literal, or an array of two-item arrays to set request's headers.

headers
: { "x-api-key": "key-2" } });
expect(
const res: Response
res
.
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
).toBe(401);
await assertDescribed(
const document: OpenApiDocument
document
, "GET /reports",
const res: Response
res
);
});
test("the route is documented as requiring a key", () => {
expect(
const document: OpenApiDocument
document
.
paths: Record<string, PathItemObject>
paths
["/reports"]?.get?.
security?: readonly Record<string, readonly string[]>[] | undefined
security
).toEqual([{
apiKey: never[]
apiKey
: [] }]);
});

The store is a Map in the test. To test the failure report, pass a store whose used rejects and a reportError that collects what it receives. More on testing is in Testing.

  • @tetsujs/core is a peer dependency. The application and the package must share one copy of the core: an HttpError from a second copy is not an instance of the application’s, and the refusal would become a 500.
  • @tetsujs/openapi is a dependency when the package annotates its hook, as in @tetsujs/rate-limit.
  • Export the options and the hook’s type — ApiKeyOptions, ApiKeyHook — so an application can pass the hook around.
  • Say which slot it goes in, and where in that slot: before or after cors(), after the hooks that provide what it Requires. The compiler checks what a hook needs, not what should come first. See Groups and mounting.