SSR framework for Svelte 5 + Bun with islands-based selective hydration
On this page
Protection Mode
Since: 0.10.0 (not released yet): Protection mode ships in 0.10.0.
Protection mode gates routes behind a browser check, like Cloudflare’s “verifying your browser” page but on your own infrastructure. An unverified client gets a 403 interstitial page: a hidden <MochiCaptchaAuto /> island runs the captcha’s hash chain and proof-of-work immediately — no slider — posts the solution to a built-in endpoint, receives a signed HttpOnly clearance cookie, and reloads into the real page. Every later request passes until the clearance expires.
await Mochi.serve({
protection: { enabled: true },
routes,
});That alone protects every route — pages, APIs, WebSockets, SSE, and unmatched URLs handled by your fetch fallback.
Choosing what to protect
protect() is an optional callback that runs on every request; return true to protect a resource. It receives { kind, path, url, request }, so branching on route kind or path prefix is one line:
protection: {
enabled: true,
// Only APIs need verification; pages stay open.
protect: ({ kind }) => kind === 'api',
},kind is 'page' | 'api' | 'ws' | 'sse' | 'island' | 'file' | 'fallback'. A protect() that throws counts as protected (fail closed).
Never gated, regardless of protect(): framework client assets (the interstitial must load its own JS/CSS), the image and local-asset endpoints (their URLs are server-minted and signed), warmup self-requests, the verify endpoint itself, and unmatched 404s with no fetch fallback. publicDir static files are gated (as kind file) — set protectFiles: false to leave them open. A blocked POST never gets the interstitial — solving one ends in a reload, which would re-submit the form — so non-GET requests fail as JSON instead.
What a blocked client sees
| Kind | Response |
|---|---|
page, fallback (GET / HEAD) | The interstitial HTML — 403, Cache-Control: no-store |
api, and any non-GET request | 403 JSON: { "error": "Browser verification required" } |
ws, sse, island, file | Plain 403 |
A browser calling a protected API from an already-cleared page carries the cookie automatically, so in practice only direct, cookie-less clients see the API 403. The non-HTML bodies use blockedMessage (default shown above).
Options
protection: {
enabled: true,
protect: ({ path }) => path.startsWith('/members'),
bits: 19, // PoW difficulty; default 19
maxAgeMs: 4 * 60 * 60 * 1000, // clearance lifetime; default 4 hours
maxAttempts: 5, // failed tries before the widget gives up; default 5
protectFiles: true, // gate publicDir static files too; default true
cookieName: '_mochi_clearance', // clearance cookie name; default shown
blockedMessage: 'Members only', // non-HTML 403 body; string or (ctx) => string
page: './src/ProtectionShell.svelte', // custom interstitial component
},bits— proof-of-work difficulty in leading zero bits; each extra bit doubles the expected work. Default: 19.maxAgeMs— how long a passed verification lasts. The clearance is a sealed{ iat }token in the clearance cookie (HttpOnly,SameSite=Lax,Path=/,Secureon https); both the cookie’sMax-Ageand the server-side check use this value. It’s keyed offMOCHI_KEY, so clearances survive restarts and work across instances.maxAttempts— after this many failed verification attempts the widget stops retrying and shows a terminal message instead (theexhaustedLabelprop on<MochiCaptchaAuto />). The count lives insessionStorage, so closing the tab resets it. Default: 5.protectFiles— also gatepublicDirstatic files (they hitprotect()as kindfile). Default:true.cookieName— rename the clearance cookie. Default:_mochi_clearance.blockedMessage— the 403 body blocked non-HTML kinds receive: theerrorfield of the api JSON and the plain-text body forws/sse/island/file. A string, or a callback receiving the same context asprotect(). Default:"Browser verification required".page— a Svelte component rendered as the interstitial, exactly likeerrorPagefor error pages. See below.
Customizing the interstitial
Point page at your own Svelte component. It renders through your app’s HTML shell, receives MochiProtectionPageProps — { token, bits, solveBudgetMs, verifyUrl, maxAttempts } — and only has to spread them onto <MochiCaptchaAuto />, which does the solving and submitting.
The built-in default below is a great natural starting point — copy it and restyle. This is the component’s live source, read from the installed mochi-framework:
<script lang="ts">
import MochiCaptchaAuto from '../../captcha/MochiCaptchaAuto.svelte';
import type { MochiProtectionPageProps } from '../../protection/types';
let { token, bits, solveBudgetMs, verifyUrl, maxAttempts }: MochiProtectionPageProps = $props();
</script>
<svelte:head>
<title>Checking your browser…</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
</svelte:head>
<div class="wrap">
<main class="stage">
<span class="brand">
<span class="logo" aria-hidden="true">🍡</span><span>mochi</span>
</span>
<p class="message">Please wait, we're validating your browser...</p>
<div class="widget">
<MochiCaptchaAuto mochi:hydrate {token} {bits} {solveBudgetMs} {verifyUrl} {maxAttempts} />
</div>
</main>
</div>
<style>
:global(html, body) {
margin: 0;
overflow-x: hidden;
}
:global(*, *::before, *::after) {
box-sizing: border-box;
}
:global(body) {
color-scheme: light dark;
}
.wrap {
--mochi-protection-bg: #f1ecdf;
--mochi-protection-ink: #2a2825;
--mochi-protection-ink-soft: #6b665e;
--mochi-protection-gradient-tint: rgba(56, 92, 71, 0.1);
--mochi-protection-font-serif: Georgia, 'Iowan Old Style', 'Palatino Linotype', Cambria, serif;
--mochi-protection-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
min-height: 100vh;
width: 100%;
background: radial-gradient(1200px 600px at 50% -10%, var(--mochi-protection-gradient-tint), transparent 60%), var(--mochi-protection-bg);
color: var(--mochi-protection-ink);
font-family: var(--mochi-protection-font-sans);
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
.stage {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
padding: 24px;
text-align: center;
}
.brand {
display: inline-flex;
align-items: baseline;
gap: 10px;
font-family: var(--mochi-protection-font-serif);
font-weight: 600;
font-size: 28px;
}
.logo {
font-size: 44px;
line-height: 1;
transform: translateY(4px);
}
.message {
font-family: var(--mochi-protection-font-serif);
font-style: italic;
font-size: 18px;
margin: 0;
color: var(--mochi-protection-ink-soft);
}
.widget {
width: min(320px, 100%);
}
@media (prefers-color-scheme: dark) {
.wrap {
--mochi-protection-bg: #1a1815;
--mochi-protection-ink: #f1ecdf;
--mochi-protection-ink-soft: #a39e94;
--mochi-protection-gradient-tint: rgba(154, 184, 163, 0.06);
}
.widget {
--mochi-captcha-border: #2d2a25;
--mochi-captcha-track-bg: #23201c;
--mochi-captcha-accent: #9ab8a3;
--mochi-captcha-accent-soft: #2f3b33;
--mochi-captcha-hint-text: #a39e94;
--mochi-captcha-error-bg: #2b1e1b;
--mochi-captcha-error-border: #4a322c;
--mochi-captcha-error-text: #e0a294;
}
}
</style>Testing protected routes
solveCaptcha() solves a minted challenge server-side, so a test can clear itself without a browser:
import { mintCaptcha, solveCaptcha } from 'mochi-framework';
const fields = solveCaptcha(mintCaptcha());
const form = new FormData();
form.set('captcha_token', fields.captcha_token);
form.set('captcha_pow', fields.captcha_pow);
const res = await fetch(`${base}/_mochi/protection/verify`, { method: 'POST', body: form, headers: { origin: base } });
const cookie = res.headers.get('Set-Cookie'); // send this on protected requestsLower captcha: { bits: 8 } in the test server so the solve takes milliseconds. Mind that the verify endpoint refuses tokens minted below the protection difficulty — if you set protection.bits above the captcha default, mint with mintCaptcha({ bits }) to match.
See it in action
Live demos showing key concepts from this page
API
| Export | What it is |
|---|---|
PROTECTION_CLEARANCE_COOKIE | The default clearance cookie name |
DEFAULT_PROTECTION_MAX_AGE_MS | Default clearance lifetime (4 hours) |
DEFAULT_PROTECTION_MAX_ATTEMPTS | Default verification attempt cut-off (5) |
MochiProtectionOptions | The Mochi.serve({ protection }) option type |
MochiProtectionContext / MochiProtectionKind | What protect() receives |
MochiProtectionPageProps | Props a custom page component receives |
PROTECTION_SHELL_COMPONENT | Absolute path of the built-in interstitial |
MochiCaptchaAuto (mochi-framework/components) | The auto-solving widget the interstitial uses |