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 is driven by @joint-ops/hitlimit-bun, and the options pass straight through.
default — see Persistence for all features.
'/api/data': Mochi.api(handler, {
rateLimit: { limit: 100, window: '1m' },
}),
'/pricing': Mochi.page('./src/Pricing.svelte', {
rateLimit: { limit: 5, window: '1m' },
}),Mochi keys requests by client IP by default. Over the limit:
- API routes return a
429JSON body ({ hitlimit: true, message, limit, remaining, resetIn }). - Page routes render your error page with status
429. Enhanced form submissions get JSON, like other form errors.
A blocked request never reaches your handle middleware — the 429 is produced before it runs, like a CSRF rejection — but it still emits the standard request event, so it appears in logging.
Every limited route’s responses carry RateLimit-* and X-RateLimit-* headers, plus Retry-After on a 429.
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
Mochi accepts all of hitlimit’s options 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
memoryStore() is the default — zero config, per process. Use SQLite for persistence across restarts, or Postgres for shared state across instances. All three are re-exported from mochi-framework, and anything else is a custom MochiRateLimitStore:
import { memoryStore, 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 }) }See Persistence for how these stores compare with the other Mochi features that persist state.
Mochi buckets counters by key within a store. A route with its own rateLimit config also folds its route pattern into the key, so different routes backed by the same database keep separate counters, even when the key resolves to the same value. Run the same route on two servers against one database and both write the same key, so they share a counter — that is how you rate-limit across a fleet. Routes inheriting the global default share one bucket per key by design.
Each store instance owns its backend — a DB connection, prepared statements, and a cleanup timer. Create the store once and share the instance. Calling sqliteStore({ path }) inline in every route config opens one connection per route to the same file, all fighting over SQLite’s single write lock.
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 bucketKeys 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, 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'
// tiered by plan
tiers: { free: { limit: 10 }, pro: { limit: 1000 } },
tier: (req, ctx) => (ctx.locals.plan as string) ?? 'free',Only counting failures
skip bypasses the limiter without consuming quota. Since the limiter runs before your middleware, re-do the credential check inside skip so only rejected attempts spend quota. A brute-force run burns the quota. Someone who knows the password is never throttled.
'/admin': Mochi.page('./src/Admin.svelte', {
rateLimit: {
limit: 10,
window: '15m',
ban: { threshold: 3, duration: '1h' },
skip: (req) => credentialsMatch(req.headers.get('Authorization')),
},
}),skip may be async, so it is also a natural place for a tarpit. An await Bun.sleep(…) on the failing branch delays the rejection without slowing a valid request:
skip: async (req) => {
const header = req.headers.get('Authorization');
// No credentials at all is a browser fetching the 401 challenge, not a guess:
// do not stall it, and do not charge it quota.
if (!header || credentialsMatch(header)) return true;
await Bun.sleep(5000);
return false;
},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, remaining, resetIn, resetAt, key, group?, tier? } — or undefined if no limiter ranSee it in action
Live demos showing key concepts from this page