@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.

or drag and drop

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.

sizedimensionsfitqualitykey
original95tours/tour_8fa21c/mn4k2p8qw1x7.webp
thumbnail300×300cover80tours/tour_8fa21c/thumb_mn4k2p8qw1x7.webp
medium800×600inside85tours/tour_8fa21c/med_mn4k2p8qw1x7.webp
large1200×900inside90tours/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.

callresult
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.