SSR framework for Svelte 5 + Bun with islands-based selective hydration
On this page
Images
Mochi transforms images on the fly with Bun.Image and serves them from an encrypted, stale-while-revalidate disk cache. Declare transforms once as named sizes in Mochi.serve(), then reference them by name. <Image> and getImageUrl() mint a signed URL only. The fetch, decode, and transform happen lazily in the /_mochi/image endpoint on the browser’s request, so SSR never blocks on image work. Every URL payload is encrypted with a key derived from your MOCHI_KEY, so the source URL stays hidden and an attacker cannot request arbitrary sources or transforms.
default — see Persistence for all features.
Declare sizes
Define transforms under Mochi.serve({ image: { sizes } }). Each size is a named recipe:
await Mochi.serve({
image: {
sizes: {
thumbnail: { width: 200, height: 200, fit: 'inside', format: 'webp', quality: 80 },
avatar: { width: 96, height: 96, fit: 'fill' },
grayscale: { width: 600, modulate: { saturation: 0 }, format: 'jpeg', quality: 85 },
},
},
routes,
});Transforms apply in a fixed order: resize → rotate → flip → flop → modulate → format-encode. Mochi validates sizes at startup. Redefining a size re-renders every URL that uses it, since a config hash is folded into the cache key and ETag.
| Size field | Default | Notes |
|---|---|---|
width / height | — | Target size; height-only derives width by ratio |
fit | 'inside' | inside keeps aspect and fits within W×H; fill stretches to W×H |
withoutEnlargement | false | Never upscale beyond the source’s intrinsic size |
rotate | none | Degrees clockwise |
flip / flop | false | Mirror vertically / horizontally |
modulate | none | { brightness?, saturation?, hue?, lightness? } (1 = unchanged) |
format | defaultFormat | webp | jpeg | png | avif |
quality | defaultQuality | 1–100 (ignored for png) |
autoOrient | global autoOrient | Apply EXIF orientation |
Bun.Imagesupportsfit: 'inside'andfit: 'fill'only. For an exact square from a non-square source usefill(which stretches). Otherwiseinsidekeeps the aspect ratio.
Component
Import Image and reference a size by name. It renders one <img> with an encrypted src and no client JavaScript. The <img> width/height default to the size’s declared dimensions.
<script>
import { Image } from 'mochi-framework/image';
</script>
<Image src="https://example.com/photo.jpg" size="thumbnail" alt="A photo" />Add placeholder to render a ThumbHash blur behind the image. It is set as the <img> background-image (no client JavaScript), and the loaded image paints over it with a CSS blur-up. The blur is computed in the background on first use, so it appears from the second render onward.
<Image src="https://example.com/photo.jpg" size="thumbnail" alt="A photo" placeholder />
| Prop | Default | Notes |
|---|---|---|
src | — | http/https URL, or a local image import (required) |
size | — | Named size; omitted → the full-size original |
alt | '' | Always set this |
placeholder | false | Background-warmed ThumbHash blur-up; pure SSR, no client JavaScript |
width / height | size’s dims | <img> attribute override |
loading / decoding | lazy/async | Passed through to <img> |
A bare <Image src> with no size serves the full-size original. An unknown size name degrades to the original and logs a one-time server warning.
<Image> works inside mochi:hydrate* islands at any depth — it detects the hydrating subtree with isHydratable(). Minting needs the server secret, so inside an island the minted URL is serialized into the page via Svelte’s hydratable and reused during hydration. If a client-side re-render changes the image props, there is no snapshot to reuse and the <img> degrades to the raw src URL.
Local image imports
Import a local image Vite-style and get an object with its served URL and intrinsic metadata:
<script>
import { Image } from 'mochi-framework/image';
import hero from './hero.png';
// hero → { src: '/_mochi/asset/hero-<hash>.png', width, height, format }
</script>
<Image src={hero} size="thumbnail" alt="A resized local photo" />
<img src={hero.src} width={hero.width} height={hero.height} alt="" />Supported formats: png, jpg, jpeg, webp, avif, gif. Put SVGs in your public/ directory and reference them with a plain <img src>.
Mochi copies the file to a content-hashed URL (/_mochi/asset/<slug>-<hash>.<ext>) and serves it from disk with a long-lived immutable cache in production. Transforms read the file from disk, so <Image src={hero} size="…"> and placeholder work without an origin round-trip. Emitted copies live under <outDir>/assets/ and relocate with the build.
The { src, width, height, format } shape is available as the exported ImportedImage type. Ambient module types come free through mochi-framework/ambient.
A bare <Image src={hero}> with no size renders the original at its intrinsic dimensions straight from that static URL. It never calls the image endpoint.
getImageUrl — deferred URLs
getImageUrl(src, size) returns an encrypted URL. It is synchronous and near-instant. No fetch happens until the browser requests it.
import { getImageUrl } from 'mochi-framework';
const url = getImageUrl('https://example.com/photo.jpg', 'thumbnail');
// → /_mochi/image/photo-thumbnail.webp?p=<encrypted token>
const original = getImageUrl('https://example.com/photo.jpg'); // no size → the originalThe URL is relative by default. To serve images from a CDN, register the image:url filter.
getImageAttrs — URL + declared dimensions
getImageAttrs(src, size?) returns getImageUrl plus the size’s declared width/height. Synchronous, server-only.
import { getImageAttrs } from 'mochi-framework';
const { url, width, height } = getImageAttrs(src, 'thumbnail');
// → { url: '/_mochi/image/…', width: 200, height: 200 }getImage — inline bytes + metadata
When you need the transformed bytes server-side (OG images, inlining, a dimension probe), getImage(src, size) runs the size inline and returns bytes plus metadata. It shares the same disk cache. Prefer getImageUrl for anything that ends up in an <img src>.
import { getImage } from 'mochi-framework';
const { bytes, contentType, width, height, format } = await getImage(src, 'thumbnail');
const original = await getImage(src); // no size → the cached original bytesPlaceholder APIs
The ThumbHash blur is also available directly. All three are server-only and share the image cache:
import { getImagePlaceholder, imagePlaceholder, warmImagePlaceholder } from 'mochi-framework';
const blur = await getImagePlaceholder(src); // compute-and-cache, blocking; data: URL or null
const maybeBlur = await imagePlaceholder(src); // non-blocking read; cached blur or null
warmImagePlaceholder(src); // fire-and-forget computeCaching & TTL
Mochi stores the original’s encoded bytes and its stale-while-revalidate timers on disk (cacheDir), so the cache survives restarts. There is one TTL — the original’s — and variants follow it:
await Mochi.serve({
image: {
timeToStale: 14_400_000, // serve fresh for 4h
timeToEvict: 86_400_000, // re-fetch source after 1 day
},
routes,
});- Fresh (within
timeToStale): served from disk. - Stale (between
timeToStaleandtimeToEvict): served immediately, source re-fetched in the background. - Expired (past
timeToEvict): re-fetched synchronously.
A background janitor reclaims entries past timeToEvict every sweepIntervalMs (default 1h). Set sweepIntervalMs: 0 to disable it.
Served images carry an ETag and a Cache-Control derived from the cache window (public, max-age=<timeToStale>, stale-while-revalidate=<timeToEvict − timeToStale>). In development no Cache-Control is sent, so edits and invalidateImage() calls always show up on the next request.
Custom cache storage
Mochi backs the image cache with FileStorage under cacheDir by default. Pass storage to swap in a different backend, for example MemoryStorage — the only other built-in one; anything else (SQLite, Postgres, Redis, …) means implementing Storage yourself. See Persistence for how this compares to other Mochi features.
import { MemoryStorage } from 'mochi-framework';
await Mochi.serve({
image: {
storage: new MemoryStorage({ maxAge: 86_400_000 }), // must be >= timeToEvict
timeToStale: 14_400_000,
timeToEvict: 86_400_000,
sizes: { thumbnail: { width: 200, height: 200 } },
},
routes,
});Invalidation
import { invalidateImage } from 'mochi-framework';
await invalidateImage(src); // mark stale: next request serves cached bytes, re-fetches in background
await invalidateImage(src, { hard: true }); // mark expired: next request blocks for a fresh re-fetchinvalidateImage() operates on the shared original, so it cascades to every variant and the ThumbHash placeholder.
Configuration
Configure under Mochi.serve({ image: { … } }). Every option is optional.
| Option | Default | Notes |
|---|---|---|
sizes | {} | Named transform recipes |
enabled | true | false unmounts the endpoint; URL helpers then return the raw source URL |
cacheDir | ./.mochi/image-cache | Must not be under publicDir; ignored when storage is set |
storage | FileStorage(cacheDir) | Override the cache backend |
defaultFormat | webp | Used when a size omits format |
defaultQuality | 80 | Used when a size omits quality |
outputFormats | all four | Allowed output formats |
allowedHosts | any public host | Exact host or *.example.com |
blockPrivateNetworks | true | Reject private/loopback/link-local addresses |
fetchTimeoutMs | 10_000 | Upstream fetch timeout |
maxResponseBytes | 20 MB | Hard source-size cap |
maxPixels | 50_000_000 | Decompression-bomb guard |
timeToStale | 14_400_000 | Cache time-to-stale (ms); variants follow it |
timeToEvict | 86_400_000 | Cache time-to-evict (ms); variants follow it |
sweepIntervalMs | 3_600_000 | Background cache-janitor interval; 0 disables |
compressPayload | true | Deflate the encrypted URL payload |
See the Named sizes demo and the Image demo.