🍡 mochi

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

Extensions (hooks & filters)

Extension points for Mochi.serve(). Two kinds:

  • Hooks run a user function at a specific framework moment (no return value).
  • Filters replace a framework default value (receive the existing value, return the new one).

Names use a namespace:camelCase convention. Each name is registered in a typed registry; whether the user callback can be sync or async is declared per name and enforced by TypeScript — sync filters in hot paths can’t accidentally be async.

Hooks:

Filters:

Hooks

mochi:init

Fires as the very first thing inside Mochi.serve(), before any framework state is set up. Async.

await Mochi.serve({
  hooks: {
    'mochi:init': async ({ options }) => {
      await warmCache(options);
    },
  },
  routes,
});

mochi:ready

Fires after Bun.serve() returns the bound server, just before Mochi.serve() resolves. Use it to do post-bind setup that needs the live Server instance (warm caches, register with service discovery, kick off background workers). Async.

await Mochi.serve({
  hooks: {
    'mochi:ready': async ({ server }) => {
      await registerWithServiceDiscovery(server.url);
    },
  },
  routes,
});

mochi:shutdown

Fires when the framework receives SIGTERM or SIGINT. The framework then calls server.stop(). A second signal force-exits with code 1. Async — the framework awaits the hook before stopping the server.

await Mochi.serve({
  hooks: {
    'mochi:shutdown': async ({ signal }) => {
      log.info(`Got ${signal}, draining…`);
      await db.close();
    },
  },
  routes,
});

The framework installs the signal listeners as part of serve(). Pre-existing user listeners on those signals are not displaced — Node.js dispatches signals to every registered listener.

route:matched

Fires when a Mochi.page / Mochi.api / Mochi.ws / Mochi.sse route matches an incoming request, after the CSRF check passes (for page/api) and before middleware/handler runs. Sync — observation only; do heavier work in a handle middleware. The kind field tells you which route type matched.

await Mochi.serve({
  hooks: {
    'route:matched': ({ pattern, kind, request }) => {
      tracer.startSpan(`${kind}:${pattern}`, { method: request.method });
    },
  },
  routes,
});

The hook does not fire when the framework rejects the request before route handling (e.g. CSRF block) — those cases reach the request event with the rejection’s status instead.

getRequestContext() is available inside the hook for all four kinds — page, api, ws, sse — and returns a context with the matched requestId, url, and params.

Filters

csrf:formContentTypes

Override the Set<string> of content types that gate the built-in CSRF check. Resolved once at startup. Sync.

await Mochi.serve({
  filters: {
    'csrf:formContentTypes': (types) => new Set([...types, 'application/csp-report']),
  },
  routes,
});

Default exported as DEFAULT_FORM_CONTENT_TYPES from mochi-framework.

csrf:protectedMethods

Override the Set<string> of HTTP methods that the CSRF check applies to. Resolved once at startup. Sync.

await Mochi.serve({
  filters: {
    'csrf:protectedMethods': (methods) => {
      methods.delete('DELETE');
      return methods;
    },
  },
  routes,
});

Default exported as DEFAULT_PROTECTED_METHODS from mochi-framework.

csrf:trustedOrigins

Override the Set<string> of cross-origin sources allowed past the CSRF check. Seeded from csrf.trustedOrigins (an array on Mochi.serve()). Resolved once at startup. Sync.

await Mochi.serve({
  filters: {
    'csrf:trustedOrigins': (origins) => {
      origins.add('https://embed.example');
      return origins;
    },
  },
  routes,
});

csrf:check

Override the framework’s CSRF decision for the current request. The filter receives the framework’s default decision — null if the request would pass, a Response (typically 403) if it would be blocked. Return the input unchanged to delegate to the framework, null to bypass, or a fresh Response to substitute a custom block.

await Mochi.serve({
  filters: {
    'csrf:check': (decision, { request, url }) => {
      // Webhook endpoint with its own auth — bypass CSRF entirely.
      if (url.pathname.startsWith('/webhooks/')) {
        return null;
      }
      return decision;
    },
  },
  routes,
});

Use sparingly: bypassing CSRF on a state-mutating endpoint reopens the attack the check exists to prevent. For most needs prefer the narrower csrf:trustedOrigins filter or csrf.checkOrigin: false on Mochi.serve().

cookie:defaults

Default CookieSerializeOptions merged into every cookies.set() call. Per-call options win on a per-field basis. path and domain from defaults also apply to cookies.delete() so the browser still matches the original Set-Cookie. Resolved once at startup. Sync.

await Mochi.serve({
  filters: {
    'cookie:defaults': () => ({ secure: true, httpOnly: true, sameSite: 'Lax', path: '/' }),
  },
  routes,
});

html:shell

Modify the HTML shell template once at startup. The value is the resolved template string with {{mochi.head}}, {{mochi.css}}, {{mochi.body}}, placeholders intact. Sync.

await Mochi.serve({
  filters: {
    'html:shell': (tpl) => tpl.replace('{{mochi.head}}', '<meta name="csp-nonce" content="abc123">{{mochi.head}}'),
  },
  routes,
});

Use this when you only want to inject a snippet — for full ownership of the shell, set htmlShell directly.

serverIsland:secretKey

Override the HMAC key used to sign server-island props. The default value is the MOCHI_KEY env var (or a fresh random key if unset). Use this to source the key from KMS / Vault / a secret manager. Async.

await Mochi.serve({
  filters: {
    'serverIsland:secretKey': async () => {
      const raw = await kms.getSecret('mochi-island-key');
      return Buffer.from(raw, 'base64url');
    },
  },
  routes,
});

The envKeyPresent field on the filter context tells you whether MOCHI_KEY was set, in case you want to fall back to the env-derived default.

compile:preprocessors

A list of Svelte PreprocessorGroup to run on every .svelte source file before compilation. Applies to both server and client targets — branch on target in the filter context if you only want one. Sync (the preprocessor list is sync; Svelte’s preprocess() itself is awaited internally).

import autoprefixer from 'autoprefixer';
import postcss from 'svelte-preprocess';

await Mochi.serve({
  filters: {
    'compile:preprocessors': () => [postcss({ postcss: { plugins: [autoprefixer] } })],
  },
  routes,
});

Default is []. Preprocessors do not currently apply to .md / .svx files (mdsvex handles those itself).

publicDir:scan

Modify the Map<urlPath, diskPath> of files served from the public directory. The filter receives a fresh copy after each scan (initial startup + every dev-mode public/ change), so in-place mutation is safe. Use it to add virtual files (entries that map to disk paths the framework didn’t discover), shadow built-in routes, or rename URLs. Async.

await Mochi.serve({
  filters: {
    'publicDir:scan': async (files) => {
      files.set('/robots.txt', '/etc/mochi/robots.generated.txt');
      return files;
    },
  },
  routes,
});

A Mochi.page / Mochi.api route on the same URL still wins — the filter only adds entries; the wiring step skips entries whose URL is already a user route, with a [mochi] warning.

{{mochi.script}}