🍡 mochi

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

On this page

Cache

MochiCache caches server-side data — typically slow upstream API calls — with stale-while-revalidate semantics. Construct once at module scope and share the instance across requests.

// src/lib/cache.ts
import { MochiCache } from 'mochi-framework';

export const pokemonCache = new MochiCache({
  minTimeToStale: 10_000, // serve fresh for 10s
  maxTimeToLive: 300_000, // hard expiry at 5min
});

Use it from a page or API route:

<script>
  import { params } from 'mochi-framework';
  import { pokemonCache } from '../lib/cache';

  const id = params.id ?? 'pikachu';

  const pokemon = await pokemonCache.fetch(`pokemon:${id}`, async () => {
    const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
    return res.ok ? await res.json() : null;
  });
</script>

Behaviour

  • Fresh (within minTimeToStale): cached value returned, no fetch.
  • Stale (between minTimeToStale and maxTimeToLive): cached value returned immediately, fetch runs in the background and updates the cache.
  • Expired (past maxTimeToLive): fetch runs synchronously and the caller waits.

API

MethodReturns
fetch(key, fn)Promise<T>
fetchWithStatus(key, fn)Promise<{ value, status }>
delete(key)Promise<void>
clearItems()Promise<void>

clearItems() empties the whole cache in one call.

status is 'fresh' \| 'stale' \| 'expired' \| 'miss'.

Options

OptionDefault
minTimeToStale5_000 (5s)
maxTimeToLive600_000 (10min)
storagein-memory Map
serializeidentity
deserializeidentity

For multi-process or persistent caching, pass a custom storage that implements getItem / setItem / removeItem / clear (e.g. Redis, SQLite via bun:sqlite). These methods may be synchronous (in-memory Map, bun:sqlite) or async / Promise-returning (Redis, network stores) — the cache awaits every call. Each key holds a single entry (the value plus its write time). When a backend needs a string or buffer — like Redis — supply serialize / deserialize to encode and decode that entry, e.g. serialize: JSON.stringify, deserialize: JSON.parse.

If a storage call throws, the cache degrades instead of failing the request: a read error recomputes via fn (reported as a miss), a write error returns the freshly computed value uncached, and a delete error is re-thrown to the caller. Every case also emits a cache:error event.

Subscribing to cache events

MochiCache emits these events on mochiEvents:

EventPayloadWhen
cache:read{ key, status }Every cache lookup, regardless of which method ran.
cache:revalidate{ key }A background refetch starts (stale read).
cache:revalidate:failed{ key, error }A background refetch threw; the stale value is still served.
cache:error{ key, operation, error }A storage get / set / remove call threw.

consoleLogger() surfaces cache:revalidate:failed and cache:error as warnings — a silently degrading upstream or storage backend is otherwise invisible.

status is 'fresh' \| 'stale' \| 'expired' \| 'miss'. Use mochiEvents.setHandler to attach a custom subscriber — it replaces a prior handler under the same name, so dev re-imports don’t pile up listeners:

import { mochiEvents } from 'mochi-framework';

mochiEvents.setHandler('metrics:cache-read', 'cache:read', ({ key, status }) => {
  metrics.increment(`cache.${status}`, { key });
});

consoleLogger() already prints cache:revalidate lines by default. Pass { cache: 'verbose' } to also print every read, or { cache: false } to silence cache logging:

import { consoleLogger } from 'mochi-framework';

consoleLogger({ cache: 'verbose' });

See the Cache Events demo for a worked example that pipes events into an in-memory ring buffer and renders them on the page.

Server-only

MochiCache lives on the server. Importing it into a hydratable island throws — caches are shared per-process state and don’t make sense in the browser. Construct cache instances in .ts modules or page-route scripts, never inside a mochi:hydrate component.