🍡 mochi

SSR framework for Svelte 5 + Bun with islands-based selective hydration

On this page

Rate limiting

Add a rateLimit config to any Mochi.page() or Mochi.api() route. It’s a thin shim around @joint-ops/hitlimit-bun — the options pass straight through.

'/api/data': Mochi.api(handler, {
  rateLimit: { limit: 100, window: '1m' },
}),
'/pricing': Mochi.page('./src/Pricing.svelte', {
  rateLimit: { limit: 5, window: '1m' },
}),

Requests are keyed by client IP by default. Over the limit:

  • API routes return a 429 JSON body ({ hitlimit: true, message, limit, remaining, resetIn }).
  • Page routes render your configured error page with status 429. Enhanced form submissions get JSON instead, like other form errors.

Blocked requests never reach your handle middleware — the 429 is produced before it runs, like CSRF rejections — but they still emit the standard request event, so they show up in logging.

Every limited route’s responses — allowed or blocked — carry RateLimit-* and X-RateLimit-* headers, plus Retry-After on a 429.

curl -i http://localhost:3333/api/data
# RateLimit-Limit: 100
# RateLimit-Remaining: 99

Global default

Set rateLimit on Mochi.serve() to cover every page and API route. Routes inheriting it share one bucket per key — a client’s hits on any of them count against the same quota. A route’s own config replaces the global one with its own bucket; rateLimit: false opts a route out.

await Mochi.serve({
  routes: {
    '/api/login': Mochi.api(login, { rateLimit: { limit: 5, window: '1m' } }), // own bucket
    '/health': Mochi.api(health, { rateLimit: false }), // exempt
  },
  rateLimit: { limit: 1000, window: '1m' }, // everything else
});

Options

All of hitlimit’s options are accepted (except logger — Mochi logs 429s through its own request events):

OptionDefault
limit100Max requests per window
window'1m''30s', '1m', '1h', '1d', or milliseconds
keyclient IP(req, ctx) => string — what to bucket by
storein-memorysqliteStore(…), postgresStore(…), or a custom MochiRateLimitStore
tiers/tierNamed limits + (req, ctx) => string tier resolver
ban{ threshold, duration } — ban repeat offenders
grouproute patternstring \| (req, ctx) => string — bucket namespace; same value → shared bucket
skip(req, ctx) => boolean — bypass without consuming quota
responsehitlimit JSONCustom 429 body (API routes)
headersall on{ standard, legacy, retryAfter }
onStoreError'allow'Fail open or 'deny' when the store errors

Stores

Memory is the default — zero config, per-process. For persistence across restarts use SQLite; for shared state across instances use Postgres. Both are re-exported from mochi-framework:

import { sqliteStore, postgresStore } from 'mochi-framework';

rateLimit: { limit: 100, window: '1m', store: sqliteStore({ path: './ratelimit.db' }) }
rateLimit: { limit: 100, window: '1m', store: postgresStore({ url: process.env.DATABASE_URL }) }

Counters are bucketed by key within a store. A route with its own rateLimit config also folds its route pattern into that key, so different routes backed by the same database — whether they share one store object or each open their own connection to it — keep separate counters, even when the key resolves to the same value (e.g. the same client IP). This is per-route pattern, not per-instance: run the same route on two servers against one database and both write the same key, so they share a counter — that’s how you rate-limit across a fleet. Routes inheriting the global default are not pattern-namespaced: they all share one bucket per key, by design.

Each store instance owns its backend — a DB connection, prepared statements, and a cleanup timer (or, for memory, a Map and a sweep timer). So don’t call sqliteStore({ path }) inline in every route config: that opens one connection per route to the same file, each with its own cleanup sweep, all fighting over SQLite’s single write lock (every hit is a write). To cover many routes against one database, create the store once and share the instance — the same object folds each route’s pattern into its keys, so you get one connection with separate per-route counters:

const store = sqliteStore({ path: './ratelimit.db' }); // one connection
'/api/search': Mochi.api(search, { rateLimit: { limit: 30, window: '1m', store } }), //own bucket
'/api/upload': Mochi.api(upload, { rateLimit: { limit: 5, window: '1m', store } }), //own bucket

Or hang it off the global default (Mochi.serve({ rateLimit: { store } })) — also one connection, but then every inheriting route shares one bucket per key.

Keys and proxies

The default key is Mochi’s proxy-aware client address — the same value as getClientAddress(), honouring proxy.addressHeader / xffDepth. Behind a reverse proxy, configure proxy or every client shares the proxy’s IP:

await Mochi.serve({ proxy: { addressHeader: 'x-forwarded-for', xffDepth: 1 }, … });

Key by anything else with key. It receives the Request plus Mochi’s request context — the same object getRequestContext() returns, so you can bucket by the proxy-aware IP, cookies, params, or your own identity. It can be async. tier, group, and skip receive the same two arguments.

// by API key (falling back to the proxy-aware IP)
key: (req, ctx) => req.headers.get('x-api-key') ?? ctx.getClientAddress() ?? 'anon'

// by logged-in user — derive identity from the request (see the note below)
key: (req, ctx) => sessionUserId(ctx.cookies) ?? ctx.getClientAddress() ?? 'anon'

// by country, from a CDN geo header
key: (req) => req.headers.get('cf-ipcountry') ?? 'unknown'

// tiered by plan
tiers: { free: { limit: 10 }, pro: { limit: 1000 } },
tier: (req, ctx) => (ctx.locals.plan as string) ?? 'free',

Reading usage server-side

An allowed request exposes its limiter state on the request context — render quotas in serverProps or any server-side code:

const rateLimit = getRequestContext().rateLimit;
// { limit: 5, remaining: 3, resetIn: 42, resetAt, key, group?, tier? } — or undefined if no limiter ran

See it in action

Live demos showing key concepts from this page