@nomideusz/svelte-media
Take a File, validate it, render four sizes with sharp, and write each one
through a pluggable storage adapter. The variable part is only where the bytes go.
pnpm add @nomideusz/svelte-media
The resizing pipeline is server-only — it needs sharp and Node's Buffer. What
runs on this page is the client half: validation, key generation, and the two components.
Upload & gallery
Both components, live. Files stay in your browser — the upload handler here returns the same StoredMedia shape the server pipeline would. The config prop
pre-validates each file before onUpload is called and routes failures to onError — it shares the limits with the playground below.
Drop an image above to populate the gallery.
Validation
validateImageFile is a pure function — this is it running, not a
reimplementation. Change the limits and drop a file that should fail.
No file checked yet.
Failures are machine-readable: code is 'empty' | 'file-too-large' | 'invalid-type', joined by the maxBytes / allowedTypes limit that produced it — so an app maps
codes to its own copy instead of surfacing the English error fallback. On the
server, processAndStore throws the same fields as MediaValidationError.
Sizes & storage keys
Every upload writes four variants. cover crops to fill; inside fits
within the box and keeps aspect ratio, so medium and large are upper bounds.
| size | dimensions | fit | quality | key |
|---|---|---|---|---|
original | — | — | 95 | tours/tour_8fa21c/mn4k2p8qw1x7.webp |
thumbnail | 300×300 | cover | 80 | tours/tour_8fa21c/thumb_mn4k2p8qw1x7.webp |
medium | 800×600 | inside | 85 | tours/tour_8fa21c/med_mn4k2p8qw1x7.webp |
large | 1200×900 | inside | 90 | tours/tour_8fa21c/large_mn4k2p8qw1x7.webp |
Filenames come from generateMediaKey() — a cuid2 with a .webp extension, because output is always WebP whatever went in. The per-size name prefixes
(thumb_, med_, large_) and WebP qualities are a
cross-app contract, so their tables are exported: SIZE_PREFIXES and SIZE_QUALITY.
One key, four variants
Apps that persist the single joined original key, rather than the (prefix,
entityId, filename) triple, get the layout back with these helpers — running here on the
key from the table above.
| call | result |
|---|---|
variantKey(key, 'thumbnail') | tours/tour_8fa21c/thumb_mn4k2p8qw1x7.webp |
parseStorageKey(key) | {"prefix":"tours","entityId":"tour_8fa21c","filename":"mn4k2p8qw1x7.webp"} |
sizeForWidth(640) | 'medium' |
parseStorageKey returns null for keys that don't fit the layout
(legacy ids), and the server-side deleteMediaByKey(adapter, key) treats those
as a no-op. sizeForWidth picks the smallest pre-generated variant that still
fills the width.
Server: the pipeline
Not runnable here — this is the half that needs sharp. Call it from a route handler; the credentials never leave the server.
import { createS3Adapter, processAndStore, deleteMediaByKey } from '@nomideusz/svelte-media/server';
const storage = createS3Adapter({
endpoint: env.S3_ENDPOINT, region: 'auto', bucket: env.S3_BUCKET,
accessKeyId: env.S3_KEY, secretAccessKey: env.S3_SECRET,
forcePathStyle: true, // false for AWS/Railway
// publicUrl is optional — private buckets serve via the next section instead
});
// Validates, resizes to 4 sizes, uploads each, returns the keys.
// A failed validation throws MediaValidationError — same code + limits as above.
const stored = await processAndStore(storage, file, 'tours', tourId);
await deleteMediaByKey(storage, stored.sizes.original); // all four variants createLocalAdapter({ root }) swaps S3 for disk in development. Any
object with put, get, delete and getUrl works.
Server: serving private buckets
Also not runnable here. Adapters read back what they wrote — get(key) resolves null for a missing object — so a private bucket needs no publicUrl at all. createMediaStore binds the adapter and limits
once per app; serveMedia is a complete same-origin route body.
import { createMediaStore, createS3Adapter } from '@nomideusz/svelte-media/server';
const media = createMediaStore({
adapter: createS3Adapter({ /* config as above */ }),
maxFileSize: 5 * 1024 * 1024,
// derive: async ({ buffer }) => … optional; result lands on stored.derived
});
// src/routes/photos/[...key]/+server.ts — key validation (incl. '..'
// traversal), 404 on missing, immutable cache headers
export const GET = ({ params }) => media.serve(params.key);
const stored = await media.store(file, 'tours', tourId); // processAndStore, bound
await media.remove(stored.sizes.original); // all four variants
// Or presign — S3 adapter only. Cached until just before expiry and deduped
// in flight, so a page with many images costs one signing pass per key.
const url = await media.signedUrl(stored.sizes.original, 'medium', 3600); The pieces work unbound too: serveMedia(adapter, key) returns a standard Response, and createS3Adapter returns an S3Adapter with getSignedUrl(key, expiresIn?). The store also exposes get, url (public buckets) and validate.