rhythmjs

Search documentation

Search guides, the tutorial and every package.

On this page

Fixed-window rate limits#

@rhythmjs/security/rate-limit counts requests per key in a fixed window: limit requests (default 100) per windowMs (default 60 000). Within the window each request increments the key's counter; once the counter passes the limit, the middleware answers 429 with a Retry-After header holding the seconds until the window resets and a JSON body of { success: false, status: 429, message } (message defaults to "Too Many Requests"). A skip(request) predicate exempts requests (health probes, allowlisted callers) before any counting happens.

TypeScript
import { rateLimit } from "@rhythmjs/security/rate-limit";

new RhythmRouter()
  .use(rateLimit({ limit: 100, windowMs: 60_000, skip: (request) => new URL(request.url).pathname === "/health" }))
  .get("/api", handler);

RateLimit-* headers#

Unless headers: false, every response, allowed or rejected, carries the draft-standard trio: RateLimit-Limit, RateLimit-Remaining (never negative), and RateLimit-Reset (seconds until the current window ends). Clients can pace themselves before ever hitting a 429.

Choosing keys#

The default key is the client IP: request.ip, which you expose in your own Bun.serve fetch (Object.defineProperty(request, "ip", { get: () => server.requestIP(request)?.address })), then the literal key "global" as a last resort (one shared bucket). X-Forwarded-For is ignored unless you set trustProxy — anyone can send that header, and a limiter keyed on it hands every client an unlimited supply of fresh buckets.

Behind a reverse proxy you must set trustProxy to the number of hops you operate (true means one): the key becomes the X-Forwarded-For entry that many hops from the right — the one your own proxy appended — so spoofed client-supplied prefixes never reach the key. Without it, every client shares the proxy's request.ip bucket and a single abuser exhausts the site-wide budget for everyone. Alternatively replace the key entirely with keyOf; an authenticated user id gives fairer buckets than an IP.

TypeScript
rateLimit({ trustProxy: true }); // one trusted hop (nginx, a load balancer)
rateLimit({ keyOf: async (request) => (await sessionOf(request))?.userId ?? "anonymous" });

Stores#

Counting goes through the RateLimitStore interface - increment(key, windowMs) returning { count, resetAt } and reset(key), so the backend is pluggable. The default memoryRateLimitStore() keeps counters in a Map and lazily sweeps expired entries at most once per window; it is per-process, which is exactly right for one instance and an undercount across replicas. For shared limits, implement the two methods over a shared store (Bun's native Bun.redis client and an INCR/PEXPIRE pair fit in a dozen lines) and pass it as store.

WebSocket upgrades#

rateLimitWs() applies the same limiter to WebSocket handshakes. It is shaped exactly like an @rhythmjs/ws middleware — (ctx, next), calling next() while the budget lasts and setting the full 429 Response (with Retry-After and the RateLimit-* headers) on ctx.response once it is spent — so it plugs into use() directly:

TypeScript
import { RhythmWs } from "@rhythmjs/ws";
import { rateLimitWs } from "@rhythmjs/security/rate-limit";

const ws = new RhythmWs()
  .use(rateLimitWs({ limit: 10, windowMs: 60_000 }))
  .route("/chat", { message(peer, message) { peer.send(String(message)); } });

Secure headers#

@rhythmjs/security/secure-headers applies helmet-style response headers after your handlers run, so nothing downstream can lose them. Ten headers are on by default; contentSecurityPolicy and crossOriginEmbedderPolicy ship off and are set only when you configure them. Every option takes a string to override the value or false to drop the header entirely.

cross-origin-opener-policy#
same-origin
cross-origin-resource-policy#
same-origin
referrer-policy#
no-referrer
strict-transport-security#
max-age=15552000; includeSubDomains
x-content-type-options#
nosniff
x-dns-prefetch-control#
off
x-download-options#
noopen
x-frame-options#
SAMEORIGIN
x-permitted-cross-domain-policies#
none
x-xss-protection#
0 (the legacy auditor, explicitly disabled)
TypeScript
import { secureHeaders } from "@rhythmjs/security/secure-headers";

new RhythmRouter().use(
  secureHeaders({
    contentSecurityPolicy: "default-src 'self'",
    xFrameOptions: "DENY",
    strictTransportSecurity: false, // dropped, e.g. behind a TLS-terminating proxy that sets it
  }),
);