Skip to content

Repository files navigation

http-policy

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.

Why

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 → runtime with 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.

Quick start (Express)

npm install @http-policy/core @http-policy/express
import 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.

What's inside a policy

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.

Runtime overrides

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.

Architecture

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.

Packages

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

Development

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

About

Type-safe HTTP policies for TypeScript — caching, security headers, CORS, ETag across Express, Fastify, Hono, NestJS and Node.js

Topics

Resources

Code of conduct

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages