Advanced Policy Techniques
This page covers advanced techniques for policy authors who are comfortable
with the basics of definePolicy() and the standard request/response
lifecycle. Each section addresses a pattern that goes beyond the fundamentals
covered in Your First Custom Policy
and Common Policy Patterns.
Construction-time validation
Section titled “Construction-time validation”The validate field in a PolicyDefinition runs once when the factory
function is called — at gateway construction time, before any requests are
processed. Use it to catch configuration errors as early as possible.
import { definePolicy, Priority, GatewayError } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
interface RateLimitByHeaderConfig extends PolicyConfig { headerName: string; maxPerWindow: number; windowSeconds: number;}
const rateLimitByHeader = definePolicy<RateLimitByHeaderConfig>({ name: "rate-limit-by-header", priority: Priority.RATE_LIMIT, validate: (config) => { if (!config.headerName) { throw new Error("rateLimitByHeader requires a headerName"); } if (config.maxPerWindow <= 0) { throw new Error("maxPerWindow must be positive"); } if (config.windowSeconds <= 0) { throw new Error("windowSeconds must be positive"); } }, handler: async (c, next, { config, debug }) => { const key = c.req.header(config.headerName) ?? "anonymous"; debug("rate limiting by %s: %s", config.headerName, key); await next(); },});Validation errors throw at gateway construction time — not on the first
request. This follows the fail-fast principle: a misconfigured policy causes
createGateway() to throw immediately, making the problem visible during
deployment or startup rather than hiding until traffic arrives.
The validate function receives the fully merged config (defaults + user
overrides). Throw a plain Error for config issues — these are developer
mistakes, not runtime conditions, so they do not need the structured
GatewayError format.
Response body transformation
Section titled “Response body transformation”Transforming a response body is a common need — wrapping JSON in an envelope,
redacting fields, or injecting metadata. The pattern requires care because
c.res is backed by a one-shot readable stream. Once you read it, you must
create a new Response.
import { definePolicy, Priority } from "@homegrower-club/stoma";
const jsonEnvelope = definePolicy({ name: "json-envelope", priority: Priority.RESPONSE_TRANSFORM, handler: async (c, next, { debug }) => { await next();
const contentType = c.res.headers.get("content-type") ?? ""; if (!contentType.includes("application/json")) return;
// Read the original body (consumes the stream) const original = await c.res.json();
// Build the wrapped response const wrapped = { data: original, meta: { timestamp: new Date().toISOString(), }, };
debug("wrapped response in envelope");
// Create a new Response with the same status and headers c.res = new Response(JSON.stringify(wrapped), { status: c.res.status, headers: c.res.headers, }); },});The content-type guard ensures non-JSON responses (HTML error pages, binary
data, streamed responses) pass through untouched. Without it, calling
c.res.json() on a non-JSON body would throw.
Composing policies into a policy pack
Section titled “Composing policies into a policy pack”When several policies always deploy together, bundle them into a single
factory that returns Policy[]. This keeps gateway configs clean and ensures
related policies are never accidentally separated.
import { definePolicy, Priority, cors } from "@homegrower-club/stoma";import type { Policy, PolicyConfig } from "@homegrower-club/stoma";
interface SecurityPackConfig { allowedOrigins?: string[]; requireHttps?: boolean;}
function securityPack(config: SecurityPackConfig = {}): Policy[] { const policies: Policy[] = [];
// Always add CORS policies.push( cors({ origins: config.allowedOrigins ?? ["*"] }), );
// Add HTTPS enforcement if enabled if (config.requireHttps) { const httpsEnforce = definePolicy({ name: "https-enforce", priority: Priority.EARLY, handler: async (c, next) => { const proto = c.req.header("x-forwarded-proto"); if (proto && proto !== "https") { return c.redirect(`https://${c.req.header("host")}${c.req.path}`, 301); } await next(); }, }); policies.push(httpsEnforce()); }
// Security headers const securityHeaders = definePolicy({ name: "security-headers", priority: Priority.RESPONSE_TRANSFORM, handler: async (c, next) => { await next(); c.res.headers.set("x-content-type-options", "nosniff"); c.res.headers.set("x-frame-options", "DENY"); c.res.headers.set("referrer-policy", "strict-origin-when-cross-origin"); }, }); policies.push(securityHeaders());
return policies;}Spread the pack into the pipeline’s policies array:
// In a route's pipeline:policies: [...securityPack({ allowedOrigins: ["https://app.example.com"] })],Because each policy in the array has its own name and priority, the
gateway’s deduplication and ordering logic works exactly as if you had listed
them individually. If a route also declares a cors() policy, the route-level
one wins (route policies override global policies with the same name).
Dynamic priority selection
Section titled “Dynamic priority selection”definePolicy() assigns a static priority at definition time. For cases where
the priority should vary based on config, use the manual Policy object
pattern instead:
function dynamicPolicy(config: { critical?: boolean }): Policy { return { name: "dynamic", priority: config.critical ? Priority.EARLY : Priority.DEFAULT, handler: async (c, next) => { await next(); }, };}This works because a Policy is just { name, priority, handler }. The
definePolicy() SDK is a convenience layer on top of this — when you need
control it does not offer, drop down to the raw interface.
You can also combine both approaches by using definePolicy() for its config
merging and debug injection, then overriding the priority on the returned
object:
const flexibleBase = definePolicy<{ runEarly?: boolean } & PolicyConfig>({ name: "flexible", priority: Priority.DEFAULT, defaults: { runEarly: false }, handler: async (c, next, { config, debug }) => { debug("running with priority override: %s", config.runEarly ? "early" : "default"); await next(); },});
// Wrapper that adjusts priority after creationfunction flexible(config?: { runEarly?: boolean }): Policy { const policy = flexibleBase(config); if (config?.runEarly) { return { ...policy, priority: Priority.EARLY }; } return policy;}Async initialization in factories
Section titled “Async initialization in factories”Some policies need to perform async work before they can handle requests —
loading a blocklist, fetching a remote config, or warming a cache. Since
createGateway() is synchronous, the async work must happen in the factory.
import { definePolicy, Priority, GatewayError } from "@homegrower-club/stoma";import type { Policy } from "@homegrower-club/stoma";
async function blocklist(url: string): Promise<Policy> { // Fetch blocklist at construction time (once, not per-request) const response = await fetch(url); const blocked = new Set((await response.json()) as string[]);
return definePolicy({ name: "blocklist", priority: Priority.IP_FILTER, handler: async (c, next, { debug }) => { const ip = c.req.header("x-forwarded-for")?.split(",")[0].trim(); if (ip && blocked.has(ip)) { debug("blocked IP: %s", ip); throw new GatewayError(403, "blocked", "IP is blocked"); } await next(); }, })();}Because the factory is async, you must await it when building the gateway:
const gw = createGateway({ routes: [{ path: "/*", methods: ["GET", "POST"], pipeline: { policies: [await blocklist("https://example.com/blocked.json")], upstream: { type: "handler", handler: (c) => c.text("OK") }, }, }],});Background work with waitUntil()
Section titled “Background work with waitUntil()”Edge runtimes like Cloudflare Workers terminate the isolate as soon as the
response is sent. To perform fire-and-forget work (webhooks, analytics,
logging) that outlives the response, use the adapter’s waitUntil() method.
import { definePolicy, Priority, getGatewayContext } from "@homegrower-club/stoma";import type { PolicyConfig } from "@homegrower-club/stoma";
const webhookNotifier = definePolicy<{ webhookUrl: string } & PolicyConfig>({ name: "webhook-notifier", priority: Priority.RESPONSE_TRANSFORM, handler: async (c, next, { config, debug }) => { await next();
// Fire-and-forget webhook -- don't block the response const ctx = getGatewayContext(c); const payload = JSON.stringify({ path: c.req.path, method: c.req.method, status: c.res.status, timestamp: new Date().toISOString(), });
const work = fetch(config.webhookUrl, { method: "POST", headers: { "content-type": "application/json" }, body: payload, }).catch((err) => { debug("webhook failed: %s", err instanceof Error ? err.message : String(err)); });
// Use waitUntil so the runtime keeps the worker alive ctx?.adapter?.waitUntil?.(work); debug("queued webhook notification"); },});getGatewayContext(c) returns the PolicyContext which includes the
adapter set on GatewayConfig. The waitUntil method tells the runtime
to keep the isolate alive until the promise settles, even though the response
has already been sent to the client.
The optional chaining (ctx?.adapter?.waitUntil?.()) is intentional — when
running outside a gateway (e.g., in a test harness without an adapter), the
call is safely skipped. The .catch() on the fetch ensures a webhook failure
does not crash the worker.
Summary
Section titled “Summary”| Technique | When to use it | Key principle |
|---|---|---|
| Construction-time validation | Complex or error-prone configs | Fail fast at build time |
| Response body transformation | Enveloping, redacting, enriching JSON | Consume stream, create new Response |
| Policy packs | Groups of policies that always deploy together | Return Policy[] from a factory |
| Dynamic priority | Priority depends on config or environment | Drop to raw Policy object |
| Async initialization | Loading remote data before first request | async factory returning Promise<Policy> |
waitUntil() background work | Analytics, webhooks, logging after response | adapter.waitUntil() keeps isolate alive |
What’s next
Section titled “What’s next”- Store-Backed Policies — policies that use adapters for persistent state (rate limits, circuit breakers).
- Custom Policies Reference — full SDK reference including
resolveConfig,policyDebug,withSkip, and the manual approach. - Recipes — complete gateway configurations combining multiple patterns.