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
minTimeToStaleandmaxTimeToLive): cached value returned immediately, fetch runs in the background and updates the cache. - Expired (past
maxTimeToLive): fetch runs synchronously and the caller waits.
API
| Method | Returns |
|---|---|
fetch(key, fn) | Promise<T> |
fetchWithStatus(key, fn) | Promise<{ value, status }> |
peek(key) | Promise<{ value, status } \| null> |
set(key, value) | Promise<void> |
markStale(key) | Promise<void> |
delete(key) | Promise<void> |
clearItems() | Promise<void> |
clearItems() empties the whole cache in one call.
peek(key) reports a key’s current status and value without running fn, revalidating, or emitting cache:read — a pure probe (returns null on a miss). markStale(key) backdates an entry so its next read is served stale-while-revalidate; it’s a no-op on a missing or already-stale key and never freshens or un-expires one. Both work through the storage interface, so they apply to any backend.
set(key, value) writes a value directly, stamped fresh, overwriting whatever is there — the counterpart to fetch, which only computes on a miss or stale read and so can’t replace a still-present entry. Reach for it instead of delete(key) followed by fetch: that sequence leaves the key absent for the whole write, and concurrent readers hitting that gap each start their own recompute.
status is 'fresh' \| 'stale' \| 'expired' \| 'miss'.
Options
| Option | Default |
|---|---|
minTimeToStale | 5_000 (5s) |
maxTimeToLive | 600_000 (10min) |
storage | in-memory Map |
serialize | identity |
deserialize | identity |
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.
The default MemoryStorage accepts { maxAge, purgeInterval } for age-based eviction, mirroring FileStorage below — new MemoryStorage({ maxAge: 300_000, purgeInterval: 60_000 }). With no options it never evicts (the prior, still-default behavior).
File-based storage
FileStorage persists each entry as a JSON file on disk, so the cache survives restarts. It’s turnkey — no serialize / deserialize needed:
import { MochiCache, FileStorage } from 'mochi-framework';
export const pokemonCache = new MochiCache({
minTimeToStale: 10_000,
maxTimeToLive: 300_000,
storage: new FileStorage({
directory: './.cache/pokemon',
maxAge: 300_000, // must be >= maxTimeToLive
}),
});Stale-while-revalidate works exactly as with in-memory storage — the entry’s write time lives inside the file. A background sweep runs on an interval to delete expired files (there’s no read-time eviction otherwise), and purgeOnInit empties the directory on startup.
Binary fields (Uint8Array / Buffer) anywhere in a value round-trip transparently — by default they’re inlined as base64 in the JSON and come back as Uint8Array, nothing to manage. For values carrying large binaries, opt into offloadBinary: true: each binary is written to its own file in a <key-hash>/ folder and replaced by a pointer in the JSON instead of base64-bloating it. Offloaded fields read back as lazy blob references — resolve one with readBlobRef(ref) (isBlobRef(value) narrows) — so a metadata read never loads the bytes. Deleting a key removes its blob folder with it, and pointers already on disk always decode, so flipping the flag never orphans existing entries. The built-in image cache enables offloading internally.
| Option | Default | |
|---|---|---|
directory | (required) | Where cache files are written; created if missing. |
purgeOnInit | false | Delete the directory’s contents when the adapter is constructed. |
purgeInterval | 60_000 (1min) | Background sweep interval in ms. <= 0 disables the sweeper. |
maxAge | 600_000 (10min) | Files older than this are deleted by the sweep. |
offloadBinary | false | Offload binary fields to per-key blob files, read back as lazy BlobRefs. |
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:
| Event | Payload | When |
|---|---|---|
cache:read | { key, status } | Every cache lookup, regardless of which method ran. |
cache:revalidate | { key } | A background refetch starts (stale read). |
cache:delete | { key } | A key was removed via delete(key). |
cache:sweep | { removed, durationMs } | A FileStorage background sweep deleted expired files. |
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 working 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.
See it in action
Live demos showing key concepts from this page