Type-safe HTTP policies for TypeScript.
Define caching, security headers, CORS and other HTTP behavior once, then apply the same policies across Node.js frameworks.
import { policy } from '@http-policy/core';
const apiPolicy = policy()
.cache((c) => c.public().browser('5m').cdn('1h').staleWhileRevalidate('10m'))
.security((s) => s.nosniff().hsts('180d').referrer('strict-origin-when-cross-origin'))
.cors((c) => c.origin('https://app.example.com').credentials())
.headers((h) => h.set('X-App-Version', '1.0'))
.compile();
// …apply it on every request — identical on every framework.Policies are compiled once into deeply immutable objects: durations parsed, header strings precomputed, configuration validated with actionable diagnostics. The request hot path is just resolve → write precomputed headers.
HTTP concerns like Cache-Control, security headers and CORS usually live in
scattered middleware and hand-written strings per route. That drifts: one route
forgets nosniff, another caches private data publicly, nobody remembers which
routes set HSTS.
http-policy treats these behaviors as policies — typed, composable,
validated, and applied identically everywhere:
- Framework-independent core. Express, Fastify, Hono and NestJS adapters apply the same policy definition; only the mechanism differs.
- Precedence you can reason about.
global → controller → route → runtimewith explicit per-category merge rules (docs/precedence.md). - Safe by default. Header names validated per RFC 9110, CRLF injection
rejected at the call site,
credentials()+ wildcard CORS is a compile-time error, private-cache contradictions produce diagnostics.
npm install @http-policy/core @http-policy/expressimport express from 'express';
import { policy } from '@http-policy/core';
import { httpPolicy, getPolicyHandle } from '@http-policy/express';
// Baseline applied to every response — define it once.
const globalPolicy = policy()
.security((s) =>
s
.nosniff()
.hsts('180d', { includeSubDomains: true })
.referrer('strict-origin-when-cross-origin')
.csp((csp) => csp.defaultSrc('self').scriptSrc('self').styleSrc('self'))
.permissions((p) => p.camera().microphone()),
)
.cors((c) =>
c
.origin(['https://app.example.com'])
.credentials()
.preflight() // OPTIONS short-circuits in middleware
.methods('GET', 'POST', 'PUT')
.maxAge('1h'),
)
.compile();
const app = express();
app.use(httpPolicy(globalPolicy));
// Route policies merge over globals — more specific categories win.
const productsPolicy = policy()
.cache((c) => c.public().browser('5m').cdn('1h').staleWhileRevalidate('10m'))
.etag((e) => e.static('products-v1')) // automatic 304 on If-None-Match
.headers((h) => h.set('X-Request-Policy', 'products'))
// Conditional branches, precompiled at .compile() time:
.when(
(ctx) => ctx.path.startsWith('/products/search'),
(b) => b.cache((c) => c.noStore()),
)
.compile();
app.get('/products', httpPolicy(productsPolicy), (_req, res) => {
res.json({ products: [] });
});
// Handlers can adjust policy per request without touching shared state:
app.get('/me', (req, res) => {
getPolicyHandle(req, res).cache((c) => c.private().maxAge('30s'));
res.json({ user: '…' });
});
app.listen(3000);Invalid combinations never compile — policy().cache((c) => c.private().cdn('1h'))
is rejected because s-maxage only exists on the public model. The same policy
definitions work unchanged with
@http-policy/node,
@http-policy/fastify,
@http-policy/hono and
@http-policy/nestjs.
| Section | Example |
|---|---|
| Cache | .cache(c => c.private().maxAge('30s').staleWhileRevalidate('1m')) |
| Security | .security(s => s.nosniff().hsts('180d', { includeSubDomains: true }).csp(csp => csp.defaultSrc('self'))) |
| CORS | .cors(c => c.origin(['https://a.example', 'https://b.example']).credentials()) |
| ETag | .etag(e => e.static('v1')), .etag(e => e.fromBody()) (opt-in hashing) |
| Custom headers | .headers(h => h.set('X-Request-Policy', 'api')) |
| Conditions | .when(ctx => ctx.user != null, b => b.cache(c => c.private())) |
Durations use human syntax ("500ms" "5m" "7d"), type-checked as template
literals and parsed once at compile time.
Handlers can adjust policy for their own request without touching shared state:
app.get('/feed', (req, res) => {
if (user.isPremium) getPolicyHandle(req, res).cache((c) => c.private());
res.json(feed);
});Overlays are request-local and lazily allocated — requests that never mutate policy pay nothing.
Developer API (immutable builders)
→ Normalize (eager validation at the call site)
→ Validate (whole-policy semantic checks)
→ Diagnostics (stable codes, actionable hints)
→ Compile (once)
→ Immutable CompiledPolicy ← frozen, shared, precomputed
→ Resolve + apply (per request)
→ Framework adapter → response
See docs/architecture.md, docs/precedence.md and the ADRs in docs/adr/ for the reasoning behind each layer.
| Package | Purpose |
|---|---|
@http-policy/core |
Framework-independent engine · zero dependencies |
@http-policy/node |
Native node:http adapter |
@http-policy/express |
Express middleware |
@http-policy/fastify |
Fastify plugin + hooks |
@http-policy/hono |
Hono middleware |
@http-policy/nestjs |
NestJS interceptor + decorators |
Runnable examples for every adapter live in examples/.
Requires Node.js 22 LTS or 24 LTS and pnpm 10.
pnpm install
pnpm build # all packages (dual ESM+CJS, attw-verified exports)
pnpm test # unit + real-HTTP integration suites
pnpm typecheck # strict TypeScript workspace-wide
pnpm lint # typed ESLint rules
pnpm validate # publint + arethetypeswrong on built packages