Your First Custom Policy
Stoma’s power comes from policies — small, composable middleware units that
inspect, transform, or reject requests as they flow through the gateway.
In this tutorial you will build a policy that adds an x-request-time header
to every response, showing how long the gateway took to process the request.
Each step builds on the last, progressively introducing more of the SDK.
-
The minimal Policy object
A
Policyis just an object with three fields:name— a unique string used for deduplication when merging global and route policies.handler— a middleware function matching Hono’sMiddlewareHandlertype.priority— a number that controls execution order (lower runs first).
That is the entire interface. Here is a policy written from scratch, with no SDK:
Try it in the editorimport { createGateway } from "@homegrower-club/stoma";import type { Policy } from "@homegrower-club/stoma";function requestTime(): Policy {return {name: "request-time",priority: 50,handler: async (c, next) => {const start = Date.now();await next();c.res.headers.set("x-request-time", `${Date.now() - start}ms`);},};}export async function createPlaygroundGateway() {return createGateway({name: "tutorial",basePath: "/api",routes: [{path: "/hello",methods: ["GET"],pipeline: {policies: [requestTime()],upstream: {type: "handler",handler: (c) => c.json({ message: "Hello from Stoma!" }),},},},],});}The handler calls
await next()to let the rest of the pipeline (and the upstream) execute, then sets a header on the response. Becausenext()is awaited, the elapsed time includes everything downstream. -
Upgrade to
definePolicy()The manual approach works, but the SDK gives you more for free. The
definePolicy()function handles config merging, conditional skip logic, debug logging, and gateway context injection automatically. It returns a factory function — call it with optional config to get aPolicy.Try it in the editorimport { createGateway, definePolicy, Priority } from "@homegrower-club/stoma";const requestTime = definePolicy({name: "request-time",priority: Priority.REQUEST_TRANSFORM,handler: async (c, next, { debug }) => {const start = Date.now();await next();const elapsed = Date.now() - start;c.res.headers.set("x-request-time", `${elapsed}ms`);debug("request processed in %dms", elapsed);},});export async function createPlaygroundGateway() {return createGateway({name: "tutorial",basePath: "/api",debug: true,routes: [{path: "/hello",methods: ["GET"],pipeline: {policies: [requestTime()],upstream: {type: "handler",handler: (c) => c.json({ message: "Hello from Stoma!" }),},},},],});}Three things changed:
Priority.REQUEST_TRANSFORMreplaces the magic number50. ThePriorityenum has named constants for every tier in the pipeline —OBSERVABILITY(0) throughMOCK(999).debugis a pre-namespaced logger scoped tostoma:policy:request-time. It is always callable — when debug is off, it is a zero-overhead no-op.definePolicyreturns a factory. You callrequestTime()(with parentheses) to get the actualPolicyobject. This is where user config will go next.
-
Add typed configuration
Most real policies need configuration. Define an interface that extends
PolicyConfig, setdefaults, and the SDK merges user overrides automatically.Try it in the editorimport { createGateway, definePolicy, Priority } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";interface RequestTimeConfig extends PolicyConfig {headerName?: string;}const requestTime = definePolicy<RequestTimeConfig>({name: "request-time",priority: Priority.REQUEST_TRANSFORM,defaults: { headerName: "x-request-time" },handler: async (c, next, { config, debug }) => {const start = Date.now();await next();const elapsed = Date.now() - start;c.res.headers.set(config.headerName!, `${elapsed}ms`);debug("set %s to %dms", config.headerName, elapsed);},});export async function createPlaygroundGateway() {return createGateway({name: "tutorial",basePath: "/api",debug: true,routes: [{path: "/hello",methods: ["GET"],pipeline: {policies: [requestTime()],upstream: {type: "handler",handler: (c) => c.json({ message: "Hello from Stoma!" }),},},},{path: "/custom",methods: ["GET"],pipeline: {policies: [requestTime({ headerName: "x-processing-time" })],upstream: {type: "handler",handler: (c) => c.json({ message: "Custom header name!" }),},},},],});}Now the factory accepts an optional config object. On
/hello, callingrequestTime()with no arguments uses the default header namex-request-time. On/custom, passing{ headerName: "x-processing-time" }overrides it.The
defaultsobject is shallow-merged with whatever the caller passes in. Fields the caller omits get the default value. -
Add error handling
Policies reject requests by throwing a
GatewayError. This short-circuits the pipeline — no further policies or upstream handler execute. The gateway catches it and returns a structured JSON response.Try it in the editorimport {createGateway,definePolicy,Priority,GatewayError,} from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";interface RequestTimeConfig extends PolicyConfig {headerName?: string;requireAuth?: boolean;}const requestTime = definePolicy<RequestTimeConfig>({name: "request-time",priority: Priority.REQUEST_TRANSFORM,defaults: { headerName: "x-request-time", requireAuth: false },handler: async (c, next, { config, debug }) => {if (config.requireAuth && !c.req.header("authorization")) {debug("rejected: missing authorization header");throw new GatewayError(401, "unauthorized", "Authorization header required");}const start = Date.now();await next();const elapsed = Date.now() - start;c.res.headers.set(config.headerName!, `${elapsed}ms`);debug("set %s to %dms", config.headerName, elapsed);},});export async function createPlaygroundGateway() {return createGateway({name: "tutorial",basePath: "/api",debug: true,routes: [{path: "/public",methods: ["GET"],pipeline: {policies: [requestTime()],upstream: {type: "handler",handler: (c) => c.json({ message: "Public endpoint" }),},},},{path: "/private",methods: ["GET"],pipeline: {policies: [requestTime({ requireAuth: true })],upstream: {type: "handler",handler: (c) => c.json({ message: "Authenticated!" }),},},},],});}Hitting
/api/privatewithout anAuthorizationheader now returns:{"error": "unauthorized","message": "Authorization header required","statusCode": 401}GatewayErroraccepts three arguments: HTTP status code, a machine-readable error code, and a human-readable message. The gateway serializes it into the structured JSON format above.
What’s next?
Section titled “What’s next?”You now know the four building blocks of every Stoma policy: the Policy
interface, definePolicy(), typed configuration, and error handling. From here:
- Common Policy Patterns — 6 real-world patterns like pre/post processing, conditional bypass, and cross-policy communication.
- Testing Custom Policies — how to test what you built using
createPolicyTestHarness(). - Policy Cookbook — 10 copy-paste recipes for common gateway tasks.
- Custom Policies Reference — full SDK reference including
resolveConfig,policyDebug,withSkip, and the manual approach.