🍡 mochi

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

On this page

Middleware (hooks)

Middleware uses SvelteKit-style Handle functions registered via Mochi.serve({ handle }). Each handle receives { event, resolve }, mutates event as needed, calls resolve(event) to continue the chain, and returns the resulting Response.

Handle

A Handle is async ({ event, resolve }) => Response. event carries { request, url, server, locals, kind }; resolve(event) invokes the next middleware or the final route handler and returns its Response.

// file: src/handle.ts
import type { Handle } from 'mochi-framework';

export const auth: Handle = async ({ event, resolve }) => {
  if (!event.request.headers.get('Authorization')) {
    return new Response('Unauthorized', { status: 401 });
  }
  return resolve(event);
};

Do NOT return without calling resolve(event) when you intend the request to continue; instead, always either short-circuit with your own Response or return resolve(event).

Do NOT call resolve(event) without await (or return) when you need to inspect or post-process the response; instead, const response = await resolve(event) and return response.

event.locals

event.locals is a per-request object for passing data between middleware layers and into route handlers. Read it from any server-side context via getRequestContext().locals.

// file: src/handle.ts
import type { Handle } from 'mochi-framework';

export const attachUser: Handle = async ({ event, resolve }) => {
  event.locals.user = await loadUser(event.request);
  return resolve(event);
};

event.kind

Every event carries a kind discriminator describing what the framework is about to do with the request:

ValueWhen
'page'Mochi.page route (GET render or POST form action)
'api'Mochi.api route
'asset'Framework static asset (.js / .css client bundle or the dev stats route)
'fallback'Unmatched URL — will be passed to your fetch handler
'error'Unmatched URL with no fetch configured — framework will render a 404

kind is set once at construction and isn’t mutated; an error thrown during a Mochi.page render is still kind: 'page'.

Use it to opt out of per-request work for framework assets:

// file: src/handle.ts
import type { Handle } from 'mochi-framework';

export const auth: Handle = async ({ event, resolve }) => {
  if (event.kind === 'asset') return resolve(event);
  if (!event.request.headers.get('Authorization')) {
    return new Response('Unauthorized', { status: 401 });
  }
  return resolve(event);
};

sequence

Compose multiple handles into one with sequence(...handlers). Handles run in order: the first handle’s pre-processing runs first, and its post-processing runs last (nested-middleware semantics).

// file: src/index.ts
import { Mochi, sequence } from 'mochi-framework';
import { auth, logging, rateLimit } from './handle';

await Mochi.serve({
  handle: sequence(auth, logging, rateLimit),
  routes: {
    '/': Mochi.page('./src/Home.svelte'),
  },
});

Do NOT assign multiple handles to handle directly; instead, wrap them in sequence().

resolve(event, opts)

resolve accepts an options bag for post-processing the response:

  • transformPage({ html, done }) — rewrite the HTML body before it is sent. See transformPage.
  • filterResponseHeaders(name, value) — return true to keep a header, false to drop it.
// file: src/handle.ts
import type { Handle } from 'mochi-framework';

export const stripServerHeader: Handle = ({ event, resolve }) =>
  resolve(event, {
    filterResponseHeaders: (name) => name.toLowerCase() !== 'server',
  });

When composed with sequence, transformPage runs in reverse order (inner handle transforms first, outer wraps the result), and filterResponseHeaders uses first-defined-wins — only the earliest handle’s filter is applied.

compress

Built-in middleware factory for response compression. Negotiates between gzip and brotli based on the client’s Accept-Encoding. Place it innermost in sequence(...) so it sees the body produced by the rest of the chain (and by transformPage):

// file: src/index.ts
import { Mochi, sequence, compress } from 'mochi-framework';

await Mochi.serve({
  handle: sequence(auth, logging, compress()),
  routes,
});

Options:

  • methods — the encodings the server is willing to use, gating which ones may be picked. Defaults to ['brotli', 'gzip']. The client’s Accept-Encoding decides the winner among them (header order + q-values; q=0 forbids). The array order is only used as a tiebreak when the client expresses no preference (e.g. Accept-Encoding: *).
  • brotliQuality — brotli quality level 0..11. Defaults to 4. The zlib default of 11 is designed for static prebuilds and is too slow for per-request SSR; raise it only when the response is cached.
// Bump brotli quality for pages you also cache
sequence(auth, compress({ brotliQuality: 6 }));

// Disable brotli (e.g. CPU-constrained host)
sequence(auth, compress({ methods: ['gzip'] }));

compress() is a no-op in development so the debug bar can render the uncompressed HTML response. In production it always adds Vary: Accept-Encoding, and compresses when the response carries a compressible Content-Type (text/*, application/json, application/javascript, application/xml, application/manifest+json, application/ld+json, image/svg+xml). Responses that already declare a Content-Encoding pass through untouched. Static framework assets (JS/CSS bundles) and the framework error page also flow through handle, so the same compress() covers them — that’s why other body-touching middleware (auth, etc.) should branch on event.kind === 'asset' if they need to skip framework bundles.

Do NOT keep compress() in front of your CDN if it already brotli/gzips at the edge; instead, drop it or pass methods: ['gzip'] to skip the per-request brotli cost.

noCache

Built-in middleware that defaults Cache-Control: no-cache on page and api responses. Routes that set their own Cache-Control are left untouched, so opt-in caching still works per route.

// file: src/index.ts
import { Mochi, sequence, noCache, compress } from 'mochi-framework';

await Mochi.serve({
  handle: sequence(noCache, compress()),
  routes,
});

asset, fallback, and error events pass through unchanged — framework bundles already get long-lived immutable caching in production. WebSocket upgrades and SSE streams never reach the middleware.