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
429JSON 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: 99Global 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):
| Option | Default | |
|---|---|---|
limit | 100 | Max requests per window |
window | '1m' | '30s', '1m', '1h', '1d', or milliseconds |
key | client IP | (req, ctx) => string — what to bucket by |
store | in-memory | sqliteStore(…), postgresStore(…), or a custom MochiRateLimitStore |
tiers/tier | — | Named limits + (req, ctx) => string tier resolver |
ban | — | { threshold, duration } — ban repeat offenders |
group | route pattern | string \| (req, ctx) => string — bucket namespace; same value → shared bucket |
skip | — | (req, ctx) => boolean — bypass without consuming quota |
response | hitlimit JSON | Custom 429 body (API routes) |
headers | all 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 bucketOr 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 ranSee it in action
Live demos showing key concepts from this page