🍡 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 is driven by @joint-ops/hitlimit-bun, and the options pass straight through.

Persistence Memory — supported, default SQLite — supported Postgres — supported File — not supported

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 429 JSON 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):

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

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 bucket

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, 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 ran

See it in action

Live demos showing key concepts from this page