Skip to content

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.


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.


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.

Try response transformation

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


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 creation
function flexible(config?: { runEarly?: boolean }): Policy {
const policy = flexibleBase(config);
if (config?.runEarly) {
return { ...policy, priority: Priority.EARLY };
}
return policy;
}

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") },
},
}],
});

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.

Try background work
TechniqueWhen to use itKey principle
Construction-time validationComplex or error-prone configsFail fast at build time
Response body transformationEnveloping, redacting, enriching JSONConsume stream, create new Response
Policy packsGroups of policies that always deploy togetherReturn Policy[] from a factory
Dynamic priorityPriority depends on config or environmentDrop to raw Policy object
Async initializationLoading remote data before first requestasync factory returning Promise<Policy>
waitUntil() background workAnalytics, webhooks, logging after responseadapter.waitUntil() keeps isolate alive

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