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 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.
: 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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
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.
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:
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.
: 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.
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";
exportfunctionmaintenance(
options: {
readonly until: () => Date |undefined;
}
options: { readonly
until: () => Date |undefined
until: () =>Date|undefined }) {
constcheck= hook.beforeParse((ctx) => {
const
constend:Date|undefined
end=
options: {
readonly until: () => Date |undefined;
}
options.
until: () => Date |undefined
until();
if (!
constend: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.
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.
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'.
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:
: 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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
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 }),
}),
}));
constapp=createApp({ routes: reports() });
const
constrequest:RequestFn
request=serve(app);
const {
constdocument: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 () => {
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.