@nomideusz/svelte-media 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,128 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 — 2026-08-14
4
+
5
+ Second pass of the same job 0.5.0 started: the parts of "handling images" both
6
+ apps still owned move here, so an app keeps only what is actually its own —
7
+ bucket names, copy, and (for yoga) what a Google Places reference costs.
8
+
9
+ ### Added
10
+ - **`adapterFromEnv(env, options?)`** — the S3_*/`MEDIA_ROOT` wiring both apps
11
+ had hand-written, including the lazy singleton each carried. Reads
12
+ `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` / `S3_ACCESS_KEY_ID` /
13
+ `S3_SECRET_ACCESS_KEY`, falls back to a local adapter on `MEDIA_ROOT`, and
14
+ otherwise throws naming what is missing — a media surface that silently
15
+ no-ops is worse than a boot failure. Memoized per resolved config, so
16
+ per-request calls reuse one client and its signed-URL cache. Takes any
17
+ `Record<string, string | undefined>`, so no `$env` import enters the package.
18
+ - **`cachedImage(adapter, key, produce, options?)`** and
19
+ **`store.cached(...)`** — bucket-as-cache in front of a remote image source.
20
+ Stored copy first (the producer is never called on a hit), one producer call
21
+ per stampede, immutable headers, `onStoreError` for the write that would
22
+ otherwise silently make you pay twice. Extracted from yoga's Google Places
23
+ hero proxy, which had grown all of it inline.
24
+ - **`fetchImage(url, options?)`** — GET an image from a remote URL, rejecting
25
+ failed and non-image responses (an error page's HTML must never land in the
26
+ cache as a photo), transcoded to WebP by default.
27
+ - **`toWebp(bytes, quality?)`** — the transcode on its own; returns the input
28
+ untouched when sharp cannot read it, because an optimisation must never lose
29
+ an image you already hold.
30
+ - **`store.resolveUrl(key, size?, expiresIn?)`** + `basePath` on
31
+ `MediaStoreConfig` — presigned when the adapter can sign, else
32
+ `basePath/<variant key>` for the same-origin route. thebest had this fallback
33
+ written by hand across two modules.
34
+ - **`DeriveSource` gains `prefix` and `entityId`** (additive), so a `derive`
35
+ hook and its `onDeriveError` can name the entity they failed on. yoga was
36
+ closing over the id to log it.
37
+
38
+ ### Fixed
39
+ - **`getStorageKey` rejects filenames containing `/`, `\\` or `..`.** Filenames
40
+ are generated, but they round-trip through DB rows and form fields before
41
+ coming back to be deleted — thebest's delete action passes one straight from
42
+ `formData`, so a crafted value reached outside the entity's own folder.
43
+ `serveMedia` already guarded the read path; this is the write/delete one.
44
+
45
+ ### Changed
46
+ - `ImageBytes.body` is `Uint8Array<ArrayBuffer>` — the concrete view `Response`
47
+ and `Blob` accept.
48
+ - README caught up with 0.5.0: `get()` on the adapter interface, `serveMedia`,
49
+ `createMediaStore`, `getSignedUrl`.
50
+
51
+ ### Adopted by the apps
52
+ - yoga: `photo-storage.ts` is now bucket names + Polish copy + the placeholder
53
+ hook (−40 lines); `/api/photo` keeps the Google policy (budget guard, ref
54
+ self-heal, negative caches) and nothing else (−60 lines, no more sharp, no
55
+ more hand-rolled in-flight map).
56
+ - thebest: `media.ts` + `image-urls.ts` lost the adapter singleton, the
57
+ presign-or-fallback branch and the split-brain addressing; the tour upload
58
+ action no longer re-states the 15 MB limit it shares with `MEDIA_CONFIG`.
59
+
60
+ ## 0.5.0 — 2026-08-09
61
+
62
+ ### Added
63
+ - **The read half of the pipeline.** `StorageAdapter` gains
64
+ `get(key): Promise<StorageObject | null>` (null = not found; real failures
65
+ reject), implemented by both adapters. Every deployment of this package was
66
+ serving from a private bucket, and both source apps had built parallel raw
67
+ S3 clients — three across the two apps — to work around the adapter being
68
+ write-only.
69
+ - **`getSignedUrl(key, expiresIn?)` on the S3 adapter** (the `S3Adapter`
70
+ type), with expiry-buffered caching and in-flight dedupe — absorbed from
71
+ thebest's hand-rolled presign layer. This is what the previously-dead
72
+ `@aws-sdk/s3-request-presigner` dependency is now for.
73
+ - **`serveMedia(adapter, key): Promise<Response>`** — the whole body of a
74
+ same-origin `/photos/[...key]`-style route: key validation (incl. `..`
75
+ traversal — one app's hand-written copy lacked the check), 404 on missing,
76
+ immutable cache headers.
77
+ - **`createMediaStore({ adapter, ...limits, derive? })`** — the per-app
78
+ binding factory both apps had hand-built (`photo-storage.ts`, `media.ts`),
79
+ returning bound `store/remove/get/serve/url/signedUrl/validate`. Env reading
80
+ stays in the app; the adapter is the seam.
81
+
82
+ ### Changed
83
+ - **Breaking for external adapter implementations:** `get` is now a required
84
+ `StorageAdapter` method. (Both in-repo apps consume the built-in adapters.)
85
+ - `S3Config.publicUrl` is now optional — both real deployments serve private
86
+ buckets and were passing dummy values to satisfy the field. `getUrl()`
87
+ throws without it.
88
+
89
+ ## 0.4.0 — 2026-08-09
90
+
91
+ ### Added
92
+ - **`SIZE_PREFIXES` and `SIZE_QUALITY` are now exported** (root and `/server`).
93
+ Both source apps had re-copied the prefix table by hand (one script copied the
94
+ quality values too) because the package kept them private — and the README had
95
+ already drifted from the code once (`lg_` vs `large_`). The variant naming is a
96
+ cross-app contract; now it has one home.
97
+ - **Joined-key helpers** for apps that persist the single original key rather
98
+ than the (prefix, entityId, filename) triple: `variantKey(key, size)`,
99
+ `parseStorageKey(key)` (null for keys that don't fit the layout, e.g. legacy
100
+ ids), and `deleteMediaByKey(adapter, key)` on `/server`. Also
101
+ `sizeForWidth(width?)` — the smallest pre-generated variant that still fills a
102
+ display width (moved in from yoga's hand-rolled copy).
103
+ - **Machine-readable validation.** `ValidationResult` gains
104
+ `code: 'empty' | 'file-too-large' | 'invalid-type'` plus `maxBytes` /
105
+ `allowedTypes`, and `processAndStore` now throws a typed
106
+ `MediaValidationError` carrying them — so a PL/EN/UK app maps `code` to its
107
+ own copy instead of surfacing the English `message`. Both apps had
108
+ re-implemented validation in 4 places purely to localize the strings.
109
+ The `empty` code also covers the zero-byte file every form action was
110
+ checking by hand.
111
+
112
+ ### Fixed
113
+ - `ImageUpload`'s declared `config` prop was never read, so the client-side
114
+ pre-validation it implied never ran. It now validates each file before
115
+ `onUpload` and routes failures to `onError`.
116
+ - README documented the large-variant prefix as `lg_`; the code writes
117
+ `large_`.
118
+
119
+ ### Changed
120
+ - `generateMediaKey()` no longer takes the ignored `originalName` parameter.
121
+ - The dead duplicate `DEFAULT_MAX_SIZE`/`DEFAULT_ALLOWED_TYPES` constants and
122
+ the unused `MediaValidation`/`MediaValidationOk` types in `core/types.ts` are
123
+ gone. (`MediaValidationError` is now the thrown error class instead of an
124
+ unused interface — technically a change to `/server`'s type surface.)
125
+
3
126
  ## 0.3.0 — 2026-08-03
4
127
 
5
128
  ### Added
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @nomideusz/svelte-media
2
2
 
3
+ [![npm](https://badgen.net/npm/v/@nomideusz/svelte-media)](https://www.npmjs.com/package/@nomideusz/svelte-media) [![license](https://badgen.net/badge/license/MIT/blue)](https://github.com/nomideusz/svelte-media/blob/main/LICENSE)
4
+
3
5
  Image upload, resizing and multi-size storage for Svelte 5 apps. One call takes
4
6
  a `File`, validates it, renders four sizes with sharp, and writes them through a
5
7
  pluggable storage adapter — S3-compatible or local disk.
@@ -32,25 +34,37 @@ the seam.
32
34
 
33
35
  ## Quick Start
34
36
 
37
+ Bind the pipeline once per app, then call it from anywhere:
38
+
35
39
  ```ts
36
- import { createS3Adapter, processAndStore, getMediaUrl } from '@nomideusz/svelte-media/server';
37
-
38
- const storage = createS3Adapter({
39
- endpoint: process.env.S3_ENDPOINT!, // https://xxx.r2.cloudflarestorage.com
40
- region: 'auto', // 'auto' for R2, 'us-east-1' for AWS
41
- bucket: process.env.S3_BUCKET!,
42
- accessKeyId: process.env.S3_KEY!,
43
- secretAccessKey: process.env.S3_SECRET!,
44
- publicUrl: process.env.S3_PUBLIC_URL!, // https://pub-xxx.r2.dev
45
- forcePathStyle: true, // true for MinIO/R2, false for Railway/AWS
46
- });
40
+ // lib/server/media.ts
41
+ import { adapterFromEnv, createMediaStore } from '@nomideusz/svelte-media/server';
42
+
43
+ export const media = () =>
44
+ createMediaStore({
45
+ adapter: adapterFromEnv(process.env, { forcePathStyle: false }),
46
+ basePath: '/api/images', // your same-origin serving route
47
+ maxFileSize: 15 * 1024 * 1024,
48
+ });
49
+ ```
50
+
51
+ `adapterFromEnv` reads `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` /
52
+ `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY`, falls back to local files when only
53
+ `MEDIA_ROOT` is set, and throws naming what is missing otherwise. It memoizes
54
+ per config, so calling it every request reuses one client (and its signed-URL
55
+ cache). Pass any `{ [key]: string | undefined }` — SvelteKit's
56
+ `$env/dynamic/private` works as-is.
47
57
 
48
- const stored = await processAndStore(storage, file, 'tours', tourId);
58
+ ```ts
59
+ const stored = await media().store(file, 'tours', tourId);
49
60
  // { filename, originalName, prefix, entityId, sizes: { original, thumbnail, medium, large } }
50
61
 
51
- const src = getMediaUrl(storage, stored.prefix, stored.entityId, stored.filename, 'medium');
62
+ const src = await media().resolveUrl(stored.sizes.original, 'medium');
52
63
  ```
53
64
 
65
+ Prefer the lower-level `createS3Adapter` / `processAndStore` when you want the
66
+ adapter built from something other than env vars.
67
+
54
68
  Store `stored` on your row — the helpers below all take
55
69
  `(prefix, entityId, filename)`, so keep those three. Nothing in the package
56
70
  touches a database.
@@ -93,7 +107,7 @@ from the callback's return type.
93
107
  | `original` | unchanged | — | *(none)* |
94
108
  | `thumbnail` | 300×300 | `cover` | `thumb_` |
95
109
  | `medium` | 800×600 | `inside` | `med_` |
96
- | `large` | 1200×900 | `inside` | `lg_` |
110
+ | `large` | 1200×900 | `inside` | `large_` |
97
111
 
98
112
  `cover` crops to fill; `inside` fits within the box and preserves aspect ratio,
99
113
  so `medium` and `large` are upper bounds rather than exact dimensions. The table
@@ -101,25 +115,32 @@ is exported as `IMAGE_SIZES`.
101
115
 
102
116
  ## Storage adapters
103
117
 
104
- The seam is three methods:
118
+ The seam is four methods:
105
119
 
106
120
  ```ts
107
121
  interface StorageAdapter {
108
122
  put(key: string, buffer: Buffer, contentType: string): Promise<void>;
123
+ get(key: string): Promise<StorageObject | null>; // null = missing; rejects on real failures
109
124
  delete(key: string): Promise<void>;
110
- getUrl(key: string): string;
125
+ getUrl(key: string): string; // throws without a publicUrl
111
126
  }
112
127
  ```
113
128
 
114
- Two ship with the package:
129
+ Two ship with the package, plus the env-driven picker:
115
130
 
116
131
  ```ts
117
- import { createS3Adapter, createLocalAdapter } from '@nomideusz/svelte-media/server';
132
+ import {
133
+ adapterFromEnv, createS3Adapter, createLocalAdapter,
134
+ } from '@nomideusz/svelte-media/server';
118
135
 
119
- createS3Adapter({ /* S3Config, above */ }); // R2, MinIO, Tigris, AWS
136
+ adapterFromEnv(process.env, { bucket: 'photos', region: 'garage' }); // S3, else MEDIA_ROOT
137
+ createS3Adapter({ /* S3Config, above */ }); // R2, MinIO, Garage, Tigris, AWS
120
138
  createLocalAdapter({ root: '/data/images' }); // writes under root, mkdir -p
121
139
  ```
122
140
 
141
+ `createS3Adapter` also returns `getSignedUrl(key, expiresIn?)` for private
142
+ buckets, with expiry-buffered caching and in-flight dedupe.
143
+
123
144
  Anything satisfying the interface works — write your own for a CDN or a test
124
145
  double.
125
146
 
@@ -150,8 +171,51 @@ import { deleteMedia } from '@nomideusz/svelte-media/server';
150
171
  await deleteMedia(storage, stored.prefix, stored.entityId, stored.filename);
151
172
  ```
152
173
 
174
+ ## Serving
175
+
176
+ A same-origin route is the whole of `serveMedia` — key validation (including
177
+ `..` traversal), 404 on missing, immutable cache headers:
178
+
179
+ ```ts
180
+ // routes/photos/[...key]/+server.ts
181
+ export const GET = ({ params }) => media().serve(params.key);
182
+ ```
183
+
184
+ `store.resolveUrl(key, size?)` picks the right URL for whichever storage you
185
+ ended up with: a presigned URL when the adapter can sign, else
186
+ `basePath/<variant key>` pointing at that route.
187
+
188
+ ## Caching remote images
189
+
190
+ For images that come from somewhere else — a billed photo API, a partner CDN —
191
+ `cached()` turns your bucket into the cache in front of it:
192
+
193
+ ```ts
194
+ import { fetchImage } from '@nomideusz/svelte-media/server';
195
+
196
+ const res = await media().cached(
197
+ `schools/${id}/hero.webp`,
198
+ () => fetchImage(`https://api.example/photo/${ref}`), // only on a miss
199
+ { onStoreError: (e, key) => log.error(`write failed for ${key}`, e) },
200
+ );
201
+ if (!res) error(404, 'No photo');
202
+ return res;
203
+ ```
204
+
205
+ A stored copy is streamed without calling the producer at all; concurrent misses
206
+ for one key share a single producer call, so a CDN stampede buys one copy rather
207
+ than one per PoP. `fetchImage` rejects non-image and failed responses (an error
208
+ page's HTML must never land in the cache as a photo) and transcodes to WebP —
209
+ `toWebp` on its own does that half, returning the input untouched if sharp
210
+ cannot read it.
211
+
153
212
  ## Components
154
213
 
214
+ > **Demo-grade.** Both production apps built on this package hand-roll their
215
+ > upload UI (multi-file queues, form-action submission, brand styling, i18n)
216
+ > and import only the server half. Treat these as starters to copy from, not
217
+ > primitives to build on.
218
+
155
219
  ```svelte
156
220
  <script lang="ts">
157
221
  import { ImageUpload, ImageGallery } from '@nomideusz/svelte-media';
@@ -188,10 +252,18 @@ the same reason: it renders on the client, where the adapter cannot go.
188
252
 
189
253
  ```ts
190
254
  // ── @nomideusz/svelte-media/server ──
191
- createS3Adapter(config: S3Config): StorageAdapter
255
+ adapterFromEnv(env, options?): StorageAdapter // S3_* / MEDIA_ROOT, memoized
256
+ createS3Adapter(config: S3Config): S3Adapter
192
257
  createLocalAdapter(config: LocalConfig): StorageAdapter
258
+ createMediaStore({ adapter, basePath?, ...limits, derive? }): MediaStore
259
+ // .store .remove .get .serve .url .signedUrl .resolveUrl .cached .validate
193
260
  processAndStore(adapter, file, prefix, entityId, config?): Promise<StoredMedia>
194
261
  deleteMedia(adapter, prefix, entityId, filename): Promise<void>
262
+ deleteMediaByKey(adapter, key): Promise<void> // joined original key
263
+ serveMedia(adapter, key): Promise<Response>
264
+ cachedImage(adapter, key, produce, options?): Promise<Response | null>
265
+ fetchImage(url, options?): Promise<ImageBytes | null>
266
+ toWebp(bytes, quality = 80): Promise<ImageBytes>
195
267
  getMediaUrl(adapter, prefix, entityId, filename, size = 'medium'): string
196
268
 
197
269
  // ── @nomideusz/svelte-media (client-safe) ──
@@ -204,8 +276,10 @@ ImageUpload, ImageGallery
204
276
 
205
277
  Key helpers are exported from both entries, so server code needs only one import.
206
278
 
207
- Types: `StorageAdapter`, `S3Config`, `LocalConfig`, `StoredMedia`, `ImageSize`,
208
- `MediaConfig`, `ValidationResult`.
279
+ Types: `StorageAdapter`, `S3Adapter`, `StorageObject`, `S3Config`,
280
+ `LocalConfig`, `MediaStore`, `MediaStoreConfig`, `StoredMedia`, `ImageSize`,
281
+ `MediaConfig`, `ValidationResult`, `ValidationErrorCode`, `DeriveSource`,
282
+ `ImageBytes`, `EnvAdapterOptions`.
209
283
 
210
284
  ## Development
211
285
 
@@ -1,8 +1,10 @@
1
- <script lang="ts">let {
1
+ <script lang="ts">import { validateImageFile } from "../core/media.js";
2
+ let {
2
3
  onUpload,
3
4
  onError,
4
5
  maxFiles = 1,
5
- accept = "image/jpeg,image/png,image/webp"
6
+ accept = "image/jpeg,image/png,image/webp",
7
+ config
6
8
  } = $props();
7
9
  let dragging = $state(false);
8
10
  let uploading = $state(false);
@@ -12,6 +14,11 @@ async function handleFiles(files) {
12
14
  uploading = true;
13
15
  try {
14
16
  for (const file of Array.from(files).slice(0, maxFiles)) {
17
+ const validation = validateImageFile(file, config);
18
+ if (!validation.valid) {
19
+ onError?.(validation.error ?? "Invalid file");
20
+ continue;
21
+ }
15
22
  await onUpload(file);
16
23
  }
17
24
  } catch (e) {
@@ -4,6 +4,7 @@ interface Props {
4
4
  onError?: (message: string) => void;
5
5
  maxFiles?: number;
6
6
  accept?: string;
7
+ /** Pre-validates each file before onUpload; failures go to onError. */
7
8
  config?: MediaConfig;
8
9
  }
9
10
  declare const ImageUpload: import("svelte").Component<Props, {}, "">;
@@ -1,2 +1,2 @@
1
- import type { S3Config, StorageAdapter } from './types.js';
2
- export declare function createS3Adapter(config: S3Config): StorageAdapter;
1
+ import type { S3Adapter, S3Config } from './types.js';
2
+ export declare function createS3Adapter(config: S3Config): S3Adapter;
@@ -1,4 +1,8 @@
1
- import { S3Client, PutObjectCommand, DeleteObjectCommand, } from '@aws-sdk/client-s3';
1
+ import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, } from '@aws-sdk/client-s3';
2
+ import { getSignedUrl as presignGetObject } from '@aws-sdk/s3-request-presigner';
3
+ // Refresh signed URLs a minute before they actually expire, so a URL handed to
4
+ // a browser never dies mid-request.
5
+ const SIGNED_TTL_BUFFER_MS = 60_000;
2
6
  export function createS3Adapter(config) {
3
7
  const client = new S3Client({
4
8
  endpoint: config.endpoint,
@@ -9,6 +13,9 @@ export function createS3Adapter(config) {
9
13
  },
10
14
  forcePathStyle: config.forcePathStyle ?? true,
11
15
  });
16
+ // ponytail: unbounded caches — media keys are a finite catalog; add LRU if that changes.
17
+ const signedCache = new Map();
18
+ const signedInflight = new Map();
12
19
  return {
13
20
  async put(key, buffer, contentType) {
14
21
  await client.send(new PutObjectCommand({
@@ -20,6 +27,27 @@ export function createS3Adapter(config) {
20
27
  CacheControl: 'public, max-age=31536000, immutable',
21
28
  }));
22
29
  },
30
+ async get(key) {
31
+ let res;
32
+ try {
33
+ res = await client.send(new GetObjectCommand({ Bucket: config.bucket, Key: key }));
34
+ }
35
+ catch (e) {
36
+ const err = e;
37
+ if (err.name === 'NoSuchKey' || err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
38
+ return null;
39
+ }
40
+ throw e;
41
+ }
42
+ const body = res.Body?.transformToWebStream();
43
+ if (!body)
44
+ return null;
45
+ return {
46
+ body,
47
+ contentType: res.ContentType ?? 'application/octet-stream',
48
+ contentLength: res.ContentLength ?? null,
49
+ };
50
+ },
23
51
  async delete(key) {
24
52
  await client.send(new DeleteObjectCommand({
25
53
  Bucket: config.bucket,
@@ -27,7 +55,34 @@ export function createS3Adapter(config) {
27
55
  }));
28
56
  },
29
57
  getUrl(key) {
58
+ if (!config.publicUrl) {
59
+ throw new Error('publicUrl not configured — serve private buckets via get() or getSignedUrl()');
60
+ }
30
61
  return `${config.publicUrl.replace(/\/$/, '')}/${key}`;
31
62
  },
63
+ async getSignedUrl(key, expiresIn = 3600) {
64
+ const cacheKey = `${expiresIn}:${key}`;
65
+ const cached = signedCache.get(cacheKey);
66
+ if (cached && cached.expiresAt > Date.now())
67
+ return cached.url;
68
+ if (cached)
69
+ signedCache.delete(cacheKey);
70
+ const inflight = signedInflight.get(cacheKey);
71
+ if (inflight)
72
+ return inflight;
73
+ const signing = presignGetObject(client, new GetObjectCommand({ Bucket: config.bucket, Key: key }), { expiresIn })
74
+ .then((url) => {
75
+ signedCache.set(cacheKey, {
76
+ url,
77
+ expiresAt: Date.now() + Math.max(expiresIn * 1000 - SIGNED_TTL_BUFFER_MS, 1_000),
78
+ });
79
+ return url;
80
+ })
81
+ .finally(() => {
82
+ signedInflight.delete(cacheKey);
83
+ });
84
+ signedInflight.set(cacheKey, signing);
85
+ return signing;
86
+ },
32
87
  };
33
88
  }
@@ -0,0 +1,23 @@
1
+ import type { StorageAdapter } from './types.js';
2
+ /** Anything with string values — `process.env`, or SvelteKit's `$env/dynamic/private`. */
3
+ export type EnvSource = Record<string, string | undefined>;
4
+ export interface EnvAdapterOptions {
5
+ /** Fallback when `S3_BUCKET` is unset. */
6
+ bucket?: string;
7
+ /** Fallback when `S3_REGION` is unset. Default `'auto'`. */
8
+ region?: string;
9
+ /** Base public URL for `getUrl()` — omit for private buckets. */
10
+ publicUrl?: string;
11
+ /** Default true (MinIO/Garage/R2); false for Railway/AWS virtual-hosted. */
12
+ forcePathStyle?: boolean;
13
+ }
14
+ /**
15
+ * The storage adapter this deployment is configured for:
16
+ *
17
+ * - S3 when `S3_ENDPOINT` + `S3_ACCESS_KEY_ID` + `S3_SECRET_ACCESS_KEY` and a
18
+ * bucket (`S3_BUCKET` or `options.bucket`) are all present
19
+ * - local files when `MEDIA_ROOT` is set (dev machines, no bucket)
20
+ * - otherwise it throws, naming what is missing — a media surface that
21
+ * silently no-ops is worse than a boot failure.
22
+ */
23
+ export declare function adapterFromEnv(env: EnvSource, options?: EnvAdapterOptions): StorageAdapter;
@@ -0,0 +1,59 @@
1
+ // Storage wiring from environment variables — server-only.
2
+ //
3
+ // Both source apps had hand-written this: read S3_*, build one lazy adapter,
4
+ // fall back (or throw) when the vars are absent. They drifted — one defaulted
5
+ // to path-style addressing for uploads while signing reads virtual-hosted, and
6
+ // each carried its own singleton. The env NAMES are the shared part; the
7
+ // defaults that differ per deployment (bucket, region, addressing) stay
8
+ // arguments.
9
+ import { createS3Adapter } from './adapter.js';
10
+ import { createLocalAdapter } from './local-adapter.js';
11
+ // One adapter per distinct config, so repeated calls (every request) reuse the
12
+ // S3 client and its signed-URL cache. Keyed by the resolved config, so a
13
+ // changed env var in dev builds a new one instead of serving a stale client.
14
+ const adapters = new Map();
15
+ /**
16
+ * The storage adapter this deployment is configured for:
17
+ *
18
+ * - S3 when `S3_ENDPOINT` + `S3_ACCESS_KEY_ID` + `S3_SECRET_ACCESS_KEY` and a
19
+ * bucket (`S3_BUCKET` or `options.bucket`) are all present
20
+ * - local files when `MEDIA_ROOT` is set (dev machines, no bucket)
21
+ * - otherwise it throws, naming what is missing — a media surface that
22
+ * silently no-ops is worse than a boot failure.
23
+ */
24
+ export function adapterFromEnv(env, options = {}) {
25
+ const endpoint = env.S3_ENDPOINT;
26
+ const accessKeyId = env.S3_ACCESS_KEY_ID;
27
+ const secretAccessKey = env.S3_SECRET_ACCESS_KEY;
28
+ const bucket = env.S3_BUCKET ?? options.bucket;
29
+ if (endpoint && accessKeyId && secretAccessKey && bucket) {
30
+ const config = {
31
+ endpoint,
32
+ region: env.S3_REGION ?? options.region ?? 'auto',
33
+ bucket,
34
+ accessKeyId,
35
+ secretAccessKey,
36
+ publicUrl: options.publicUrl,
37
+ forcePathStyle: options.forcePathStyle,
38
+ };
39
+ return memo(`s3:${JSON.stringify(config)}`, () => createS3Adapter(config));
40
+ }
41
+ const root = env.MEDIA_ROOT;
42
+ if (root)
43
+ return memo(`local:${root}`, () => createLocalAdapter({ root }));
44
+ const missing = [
45
+ !endpoint && 'S3_ENDPOINT',
46
+ !accessKeyId && 'S3_ACCESS_KEY_ID',
47
+ !secretAccessKey && 'S3_SECRET_ACCESS_KEY',
48
+ !bucket && 'S3_BUCKET',
49
+ ].filter(Boolean);
50
+ throw new Error(`svelte-media: no storage configured — missing ${missing.join(', ')} (or set MEDIA_ROOT for local files)`);
51
+ }
52
+ function memo(key, build) {
53
+ let adapter = adapters.get(key);
54
+ if (!adapter) {
55
+ adapter = build();
56
+ adapters.set(key, adapter);
57
+ }
58
+ return adapter;
59
+ }
@@ -1,5 +1,11 @@
1
- import { writeFile, unlink, mkdir } from 'node:fs/promises';
2
- import { join, dirname } from 'node:path';
1
+ import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
2
+ import { join, dirname, extname } from 'node:path';
3
+ const MIME_BY_EXT = {
4
+ '.webp': 'image/webp',
5
+ '.png': 'image/png',
6
+ '.jpg': 'image/jpeg',
7
+ '.jpeg': 'image/jpeg',
8
+ };
3
9
  export function createLocalAdapter(config) {
4
10
  return {
5
11
  async put(key, buffer, contentType) {
@@ -7,6 +13,22 @@ export function createLocalAdapter(config) {
7
13
  await mkdir(dirname(filePath), { recursive: true });
8
14
  await writeFile(filePath, buffer);
9
15
  },
16
+ async get(key) {
17
+ let buffer;
18
+ try {
19
+ buffer = await readFile(join(config.root, key));
20
+ }
21
+ catch (e) {
22
+ if (e.code === 'ENOENT')
23
+ return null;
24
+ throw e;
25
+ }
26
+ return {
27
+ body: new Blob([new Uint8Array(buffer)]).stream(),
28
+ contentType: MIME_BY_EXT[extname(key).toLowerCase()] ?? 'application/octet-stream',
29
+ contentLength: buffer.length,
30
+ };
31
+ },
10
32
  async delete(key) {
11
33
  await unlink(join(config.root, key)).catch(() => { });
12
34
  },
@@ -1,4 +1,4 @@
1
- import type { ImageSize, ValidationConfig, ValidationResult } from './types.js';
1
+ import type { ImageSize, ValidationConfig, ValidationErrorCode, ValidationResult } from './types.js';
2
2
  export declare const DEFAULT_MAX_SIZE: number;
3
3
  export declare const DEFAULT_ALLOWED_TYPES: string[];
4
4
  export declare const IMAGE_SIZES: {
@@ -21,5 +21,34 @@ export declare const IMAGE_SIZES: {
21
21
  export declare const SIZE_PREFIXES: Record<ImageSize, string>;
22
22
  export declare const SIZE_QUALITY: Record<ImageSize, number>;
23
23
  export declare function validateImageFile(file: File, config?: ValidationConfig): ValidationResult;
24
- export declare function generateMediaKey(_originalName?: string): string;
24
+ /**
25
+ * What `processAndStore` throws on a failed validation. Carries the
26
+ * machine-readable `code` (+ limits) so form actions can map it to their own
27
+ * copy instead of surfacing the English `message`.
28
+ */
29
+ export declare class MediaValidationError extends Error {
30
+ readonly code: ValidationErrorCode;
31
+ readonly maxBytes?: number;
32
+ readonly allowedTypes?: string[];
33
+ constructor(result: ValidationResult);
34
+ }
35
+ export declare function generateMediaKey(): string;
25
36
  export declare function getStorageKey(prefix: string, entityId: string, filename: string, size: ImageSize): string;
37
+ /**
38
+ * The variant key for a stored ORIGINAL key (`prefix/entityId/filename`) —
39
+ * for apps that persist the single joined key rather than the
40
+ * (prefix, entityId, filename) triple.
41
+ */
42
+ export declare function variantKey(key: string, size: ImageSize): string;
43
+ /** The smallest pre-generated variant that still fills `width` display pixels. */
44
+ export declare function sizeForWidth(width?: number): ImageSize;
45
+ /**
46
+ * Inverse of `getStorageKey` for an original key. Returns null for keys that
47
+ * don't fit the `prefix/entityId/filename` layout (e.g. legacy ids), so
48
+ * callers can treat those as "nothing to do".
49
+ */
50
+ export declare function parseStorageKey(key: string): {
51
+ prefix: string;
52
+ entityId: string;
53
+ filename: string;
54
+ } | null;
@@ -24,20 +24,79 @@ export const SIZE_QUALITY = {
24
24
  export function validateImageFile(file, config) {
25
25
  const maxSize = config?.maxFileSize ?? DEFAULT_MAX_SIZE;
26
26
  const allowed = config?.allowedTypes ?? DEFAULT_ALLOWED_TYPES;
27
+ if (file.size === 0) {
28
+ return { valid: false, code: 'empty', error: 'No file provided' };
29
+ }
27
30
  if (file.size > maxSize) {
28
31
  const mb = Math.round(maxSize / 1024 / 1024);
29
- return { valid: false, error: `File too large (max ${mb}MB)` };
32
+ return { valid: false, code: 'file-too-large', maxBytes: maxSize, error: `File too large (max ${mb}MB)` };
30
33
  }
31
34
  if (!allowed.includes(file.type)) {
32
- return { valid: false, error: `Invalid file type. Allowed: JPEG, PNG, WebP` };
35
+ return { valid: false, code: 'invalid-type', allowedTypes: allowed, error: `Invalid file type. Allowed: JPEG, PNG, WebP` };
33
36
  }
34
37
  return { valid: true };
35
38
  }
36
- export function generateMediaKey(_originalName) {
39
+ /**
40
+ * What `processAndStore` throws on a failed validation. Carries the
41
+ * machine-readable `code` (+ limits) so form actions can map it to their own
42
+ * copy instead of surfacing the English `message`.
43
+ */
44
+ export class MediaValidationError extends Error {
45
+ code;
46
+ maxBytes;
47
+ allowedTypes;
48
+ constructor(result) {
49
+ super(result.error ?? 'Invalid file');
50
+ this.name = 'MediaValidationError';
51
+ this.code = result.code ?? 'invalid-type';
52
+ this.maxBytes = result.maxBytes;
53
+ this.allowedTypes = result.allowedTypes;
54
+ }
55
+ }
56
+ export function generateMediaKey() {
37
57
  // All processed output is WebP regardless of the uploaded format.
38
58
  return `${createId()}.webp`;
39
59
  }
40
60
  export function getStorageKey(prefix, entityId, filename, size) {
61
+ // Filenames are generated (`<cuid2>.webp`), but they round-trip through rows
62
+ // and form fields before they come back here to be deleted — a `..` segment
63
+ // would reach outside the entity's own folder. serveMedia guards the read
64
+ // path; this is the write/delete one.
65
+ if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
66
+ throw new Error(`Invalid media filename: ${filename}`);
67
+ }
41
68
  const sizePrefix = SIZE_PREFIXES[size];
42
69
  return `${prefix}/${entityId}/${sizePrefix}${filename}`;
43
70
  }
71
+ /**
72
+ * The variant key for a stored ORIGINAL key (`prefix/entityId/filename`) —
73
+ * for apps that persist the single joined key rather than the
74
+ * (prefix, entityId, filename) triple.
75
+ */
76
+ export function variantKey(key, size) {
77
+ const idx = key.lastIndexOf('/');
78
+ return `${key.slice(0, idx + 1)}${SIZE_PREFIXES[size]}${key.slice(idx + 1)}`;
79
+ }
80
+ /** The smallest pre-generated variant that still fills `width` display pixels. */
81
+ export function sizeForWidth(width) {
82
+ if (!width)
83
+ return 'medium';
84
+ if (width <= IMAGE_SIZES.thumbnail.width)
85
+ return 'thumbnail';
86
+ if (width <= IMAGE_SIZES.medium.width)
87
+ return 'medium';
88
+ return 'large';
89
+ }
90
+ /**
91
+ * Inverse of `getStorageKey` for an original key. Returns null for keys that
92
+ * don't fit the `prefix/entityId/filename` layout (e.g. legacy ids), so
93
+ * callers can treat those as "nothing to do".
94
+ */
95
+ export function parseStorageKey(key) {
96
+ const parts = key.split('/');
97
+ if (parts.length < 3)
98
+ return null;
99
+ const filename = parts.pop();
100
+ const entityId = parts.pop();
101
+ return { prefix: parts.join('/'), entityId, filename };
102
+ }
@@ -1,4 +1,10 @@
1
1
  import type { StorageAdapter, StoredMedia, ImageSize, MediaConfig } from './types.js';
2
2
  export declare function processAndStore<TDerived = never>(adapter: StorageAdapter, file: File, prefix: string, entityId: string, config?: MediaConfig<TDerived>): Promise<StoredMedia<TDerived>>;
3
3
  export declare function deleteMedia(adapter: StorageAdapter, prefix: string, entityId: string, filename: string): Promise<void>;
4
+ /**
5
+ * `deleteMedia` for apps that persist the single joined original key.
6
+ * Keys that don't parse (legacy ids from a previous storage system) are a
7
+ * silent no-op — there is nothing in object storage to delete.
8
+ */
9
+ export declare function deleteMediaByKey(adapter: StorageAdapter, key: string): Promise<void>;
4
10
  export declare function getMediaUrl(adapter: StorageAdapter, prefix: string, entityId: string, filename: string, size?: ImageSize): string;
@@ -2,12 +2,12 @@
2
2
  // browser bundle will fail — the client-safe helpers live in core/media.ts and
3
3
  // are re-exported from the package root.
4
4
  import sharp from 'sharp';
5
- import { IMAGE_SIZES, SIZE_QUALITY, generateMediaKey, getStorageKey, validateImageFile, } from './media.js';
5
+ import { IMAGE_SIZES, SIZE_QUALITY, MediaValidationError, generateMediaKey, getStorageKey, parseStorageKey, validateImageFile, } from './media.js';
6
6
  export async function processAndStore(adapter, file, prefix, entityId, config) {
7
7
  const validation = validateImageFile(file, config);
8
8
  if (!validation.valid)
9
- throw new Error(validation.error);
10
- const filename = generateMediaKey(file.name);
9
+ throw new MediaValidationError(validation);
10
+ const filename = generateMediaKey();
11
11
  const arrayBuffer = await file.arrayBuffer();
12
12
  const buffer = Buffer.from(arrayBuffer);
13
13
  const sizes = {
@@ -34,7 +34,7 @@ export async function processAndStore(adapter, file, prefix, entityId, config) {
34
34
  // make the caller retry a completed upload.
35
35
  let derived;
36
36
  if (config?.derive) {
37
- const source = { buffer, filename, mimeType: file.type };
37
+ const source = { buffer, filename, mimeType: file.type, prefix, entityId };
38
38
  try {
39
39
  derived = await config.derive(source);
40
40
  }
@@ -48,6 +48,17 @@ export async function deleteMedia(adapter, prefix, entityId, filename) {
48
48
  const keys = ['original', 'thumbnail', 'medium', 'large'].map((size) => getStorageKey(prefix, entityId, filename, size));
49
49
  await Promise.all(keys.map((key) => adapter.delete(key).catch(() => { })));
50
50
  }
51
+ /**
52
+ * `deleteMedia` for apps that persist the single joined original key.
53
+ * Keys that don't parse (legacy ids from a previous storage system) are a
54
+ * silent no-op — there is nothing in object storage to delete.
55
+ */
56
+ export async function deleteMediaByKey(adapter, key) {
57
+ const parsed = parseStorageKey(key);
58
+ if (!parsed)
59
+ return;
60
+ await deleteMedia(adapter, parsed.prefix, parsed.entityId, parsed.filename);
61
+ }
51
62
  export function getMediaUrl(adapter, prefix, entityId, filename, size = 'medium') {
52
63
  return adapter.getUrl(getStorageKey(prefix, entityId, filename, size));
53
64
  }
@@ -0,0 +1,42 @@
1
+ import type { StorageAdapter } from './types.js';
2
+ export interface ImageBytes {
3
+ /** Concrete ArrayBuffer view — `Response` and `Blob` reject the SharedArrayBuffer-wide type. */
4
+ body: Uint8Array<ArrayBuffer>;
5
+ contentType: string;
6
+ }
7
+ /**
8
+ * Re-encode to WebP (~30-50% smaller than the JPEG most photo APIs serve).
9
+ * Returns the input untouched when sharp cannot read it — a transcode is an
10
+ * optimisation, never a reason to lose an image we already hold.
11
+ */
12
+ export declare function toWebp(bytes: Uint8Array<ArrayBuffer>, quality?: number): Promise<ImageBytes>;
13
+ export interface FetchImageOptions extends RequestInit {
14
+ /** WebP quality, or `false` to keep the upstream encoding. Default 80. */
15
+ webp?: number | false;
16
+ }
17
+ /**
18
+ * GET an image from a remote URL, transcoded to WebP by default.
19
+ * `null` when the response fails or isn't an image — an error page's HTML must
20
+ * never reach the cache as if it were a photo.
21
+ */
22
+ export declare function fetchImage(url: string, options?: FetchImageOptions): Promise<ImageBytes | null>;
23
+ export interface CachedImageOptions {
24
+ /**
25
+ * Called when the fetched bytes could not be stored. The response still goes
26
+ * out; the next request will produce them again (i.e. pay again), so wire
27
+ * this to your logger.
28
+ */
29
+ onStoreError?(error: unknown, key: string): void;
30
+ }
31
+ /**
32
+ * Serve `key` from storage, producing it once on the first miss.
33
+ *
34
+ * Cheapest path first: a stored copy is streamed without calling `produce` at
35
+ * all. Concurrent misses for the same key share one `produce` call — a CDN
36
+ * stampede across PoPs, or a crawler, otherwise buys one copy each.
37
+ *
38
+ * `null` means `produce` had nothing (the caller decides: 404, placeholder,
39
+ * fall through). A storage read that throws is treated as a miss: an
40
+ * unreachable bucket must not blank an image it could still fetch.
41
+ */
42
+ export declare function cachedImage(adapter: StorageAdapter, key: string, produce: () => Promise<ImageBytes | null>, options?: CachedImageOptions): Promise<Response | null>;
@@ -0,0 +1,105 @@
1
+ // Caching images that come from somewhere else — a paid photo API, a partner
2
+ // CDN, a scraper. Server-only (sharp).
3
+ //
4
+ // Extracted from yoga's Google Places hero proxy, which was paying Google per
5
+ // origin miss until it grew a bucket cache, a stampede lock and a transcode
6
+ // step around it. All three are storage plumbing, not Places knowledge: what
7
+ // stays in the app is which URL to call, what it costs and when to give up.
8
+ import sharp from 'sharp';
9
+ // Cached objects are keyed by content and never rewritten in place, so the
10
+ // bytes behind a URL never change — same header serveMedia sends.
11
+ const IMMUTABLE = 'public, max-age=31536000, immutable';
12
+ /**
13
+ * Re-encode to WebP (~30-50% smaller than the JPEG most photo APIs serve).
14
+ * Returns the input untouched when sharp cannot read it — a transcode is an
15
+ * optimisation, never a reason to lose an image we already hold.
16
+ */
17
+ export async function toWebp(bytes, quality = 80) {
18
+ try {
19
+ const webp = await sharp(Buffer.from(bytes)).webp({ quality }).toBuffer();
20
+ return { body: new Uint8Array(webp), contentType: 'image/webp' };
21
+ }
22
+ catch {
23
+ return { body: bytes, contentType: 'application/octet-stream' };
24
+ }
25
+ }
26
+ /**
27
+ * GET an image from a remote URL, transcoded to WebP by default.
28
+ * `null` when the response fails or isn't an image — an error page's HTML must
29
+ * never reach the cache as if it were a photo.
30
+ */
31
+ export async function fetchImage(url, options = {}) {
32
+ const { webp = 80, ...init } = options;
33
+ const res = await fetch(url, { redirect: 'follow', ...init });
34
+ if (!res.ok)
35
+ return null;
36
+ const contentType = res.headers.get('content-type') ?? '';
37
+ if (!contentType.startsWith('image/'))
38
+ return null;
39
+ const bytes = new Uint8Array(await res.arrayBuffer());
40
+ if (webp === false)
41
+ return { body: bytes, contentType };
42
+ const encoded = await toWebp(bytes, webp);
43
+ // sharp failed — keep the upstream type rather than toWebp's octet-stream.
44
+ return encoded.contentType === 'image/webp' ? encoded : { body: bytes, contentType };
45
+ }
46
+ // Per-adapter, so two stores in one process cannot collide on a shared key.
47
+ const inflight = new WeakMap();
48
+ /**
49
+ * Serve `key` from storage, producing it once on the first miss.
50
+ *
51
+ * Cheapest path first: a stored copy is streamed without calling `produce` at
52
+ * all. Concurrent misses for the same key share one `produce` call — a CDN
53
+ * stampede across PoPs, or a crawler, otherwise buys one copy each.
54
+ *
55
+ * `null` means `produce` had nothing (the caller decides: 404, placeholder,
56
+ * fall through). A storage read that throws is treated as a miss: an
57
+ * unreachable bucket must not blank an image it could still fetch.
58
+ */
59
+ export async function cachedImage(adapter, key, produce, options = {}) {
60
+ const stored = await adapter.get(key).catch(() => null);
61
+ if (stored) {
62
+ return new Response(stored.body, {
63
+ headers: {
64
+ 'Content-Type': stored.contentType,
65
+ ...(stored.contentLength != null
66
+ ? { 'Content-Length': String(stored.contentLength) }
67
+ : {}),
68
+ 'Cache-Control': IMMUTABLE,
69
+ },
70
+ });
71
+ }
72
+ let pending = inflight.get(adapter);
73
+ if (!pending) {
74
+ pending = new Map();
75
+ inflight.set(adapter, pending);
76
+ }
77
+ let work = pending.get(key);
78
+ if (!work) {
79
+ work = (async () => {
80
+ const image = await produce();
81
+ if (!image)
82
+ return null;
83
+ // Awaited: a silent write failure means paying for these bytes again on
84
+ // the next request, which is the whole point of the cache.
85
+ try {
86
+ await adapter.put(key, Buffer.from(image.body), image.contentType);
87
+ }
88
+ catch (error) {
89
+ options.onStoreError?.(error, key);
90
+ }
91
+ return image;
92
+ })().finally(() => pending.delete(key));
93
+ pending.set(key, work);
94
+ }
95
+ const image = await work;
96
+ if (!image)
97
+ return null;
98
+ return new Response(image.body, {
99
+ headers: {
100
+ 'Content-Type': image.contentType,
101
+ 'Content-Length': String(image.body.byteLength),
102
+ 'Cache-Control': IMMUTABLE,
103
+ },
104
+ });
105
+ }
@@ -0,0 +1,8 @@
1
+ import type { StorageAdapter } from './types.js';
2
+ /**
3
+ * Same-origin media serving: the whole body of a
4
+ * `/photos/[...key]`-style route. Key validation, 404 on missing, immutable
5
+ * cache headers (keys are content-addressed and never change in place).
6
+ * Returns a standard `Response` — return it directly from a SvelteKit handler.
7
+ */
8
+ export declare function serveMedia(adapter: StorageAdapter, key: string): Promise<Response>;
@@ -0,0 +1,26 @@
1
+ // Media keys are `prefix/entityId/(sizePrefix)filename.ext` — reject anything
2
+ // else (especially `..` traversal) before it reaches the adapter. Both source
3
+ // apps had written this route by hand; the copy without the guard had a path
4
+ // traversal, which is why the validation lives here now.
5
+ const MEDIA_KEY = /^[a-zA-Z0-9_/.-]+\.(webp|jpe?g|png)$/;
6
+ /**
7
+ * Same-origin media serving: the whole body of a
8
+ * `/photos/[...key]`-style route. Key validation, 404 on missing, immutable
9
+ * cache headers (keys are content-addressed and never change in place).
10
+ * Returns a standard `Response` — return it directly from a SvelteKit handler.
11
+ */
12
+ export async function serveMedia(adapter, key) {
13
+ if (!MEDIA_KEY.test(key) || key.includes('..')) {
14
+ return new Response('Not found', { status: 404 });
15
+ }
16
+ const obj = await adapter.get(key);
17
+ if (!obj)
18
+ return new Response('Not found', { status: 404 });
19
+ return new Response(obj.body, {
20
+ headers: {
21
+ 'Content-Type': obj.contentType,
22
+ ...(obj.contentLength != null ? { 'Content-Length': String(obj.contentLength) } : {}),
23
+ 'Cache-Control': 'public, max-age=31536000, immutable',
24
+ },
25
+ });
26
+ }
@@ -0,0 +1,48 @@
1
+ import type { ImageSize, MediaConfig, StorageAdapter, StorageObject, StoredMedia, ValidationResult } from './types.js';
2
+ import { type CachedImageOptions, type ImageBytes } from './remote.js';
3
+ export interface MediaStoreConfig<TDerived = never> extends MediaConfig<TDerived> {
4
+ adapter: StorageAdapter;
5
+ /**
6
+ * Where the same-origin serving route lives, e.g. `'/photos'` or
7
+ * `'/api/images'` — what `resolveUrl` falls back to when the adapter cannot
8
+ * presign (local dev, public buckets).
9
+ */
10
+ basePath?: string;
11
+ }
12
+ /**
13
+ * The per-app binding — adapter, limits and derive hook bound once, so call
14
+ * sites pass only what varies per upload. Both source apps had hand-built this
15
+ * module (and one ended up configuring the same bucket two different ways);
16
+ * app #3 starts here instead.
17
+ *
18
+ * Env reading stays in the app: build the adapter from your own env names and
19
+ * hand it in.
20
+ */
21
+ export interface MediaStore<TDerived = never> {
22
+ adapter: StorageAdapter;
23
+ /** `processAndStore` with the bound adapter + config. */
24
+ store(file: File, prefix: string, entityId: string): Promise<StoredMedia<TDerived>>;
25
+ /** Deletes all variants by the joined original key; unparseable keys are a no-op. */
26
+ remove(key: string): Promise<void>;
27
+ get(key: string): Promise<StorageObject | null>;
28
+ /** `serveMedia` with the bound adapter — a complete same-origin route body. */
29
+ serve(key: string): Promise<Response>;
30
+ /** Public URL for a size variant (adapters with a publicUrl only). */
31
+ url(key: string, size?: ImageSize): string;
32
+ /** Presigned URL for a size variant — rejects unless the adapter is S3. */
33
+ signedUrl(key: string, size?: ImageSize, expiresIn?: number): Promise<string>;
34
+ /**
35
+ * The URL to hand a browser: presigned when the adapter can sign (a private
36
+ * bucket in production), else `basePath/key` for the same-origin route.
37
+ * Both apps had written that fallback by hand.
38
+ */
39
+ resolveUrl(key: string, size?: ImageSize, expiresIn?: number): Promise<string>;
40
+ /**
41
+ * `cachedImage` with the bound adapter — serve a key from storage, buying it
42
+ * from `produce` once on the first miss.
43
+ */
44
+ cached(key: string, produce: () => Promise<ImageBytes | null>, options?: CachedImageOptions): Promise<Response | null>;
45
+ /** `validateImageFile` with the bound limits. */
46
+ validate(file: File): ValidationResult;
47
+ }
48
+ export declare function createMediaStore<TDerived = never>(config: MediaStoreConfig<TDerived>): MediaStore<TDerived>;
@@ -0,0 +1,33 @@
1
+ import { validateImageFile, variantKey } from './media.js';
2
+ import { processAndStore, deleteMediaByKey } from './process.js';
3
+ import { serveMedia } from './serve.js';
4
+ import { cachedImage } from './remote.js';
5
+ export function createMediaStore(config) {
6
+ const { adapter, basePath, ...media } = config;
7
+ const signer = adapter.getSignedUrl?.bind(adapter);
8
+ return {
9
+ adapter,
10
+ store: (file, prefix, entityId) => processAndStore(adapter, file, prefix, entityId, media),
11
+ remove: (key) => deleteMediaByKey(adapter, key),
12
+ get: (key) => adapter.get(key),
13
+ serve: (key) => serveMedia(adapter, key),
14
+ url: (key, size = 'original') => adapter.getUrl(variantKey(key, size)),
15
+ signedUrl: (key, size = 'original', expiresIn) => {
16
+ if (!signer) {
17
+ return Promise.reject(new Error('signedUrl needs an S3 adapter (getSignedUrl)'));
18
+ }
19
+ return signer(variantKey(key, size), expiresIn);
20
+ },
21
+ resolveUrl: async (key, size = 'original', expiresIn) => {
22
+ const variant = variantKey(key, size);
23
+ if (signer)
24
+ return signer(variant, expiresIn);
25
+ if (!basePath) {
26
+ throw new Error('resolveUrl needs an S3 adapter or a basePath for the serving route');
27
+ }
28
+ return `${basePath.replace(/\/$/, '')}/${variant}`;
29
+ },
30
+ cached: (key, produce, options) => cachedImage(adapter, key, produce, options),
31
+ validate: (file) => validateImageFile(file, media),
32
+ };
33
+ }
@@ -1,9 +1,27 @@
1
1
  export type ImageSize = 'original' | 'thumbnail' | 'medium' | 'large';
2
+ /** What `StorageAdapter.get` resolves for an existing object. */
3
+ export interface StorageObject {
4
+ body: ReadableStream<Uint8Array>;
5
+ contentType: string;
6
+ contentLength: number | null;
7
+ }
2
8
  export interface StorageAdapter {
3
9
  put(key: string, buffer: Buffer, contentType: string): Promise<void>;
10
+ /** null when the object doesn't exist; rejects on real failures (network, auth). */
11
+ get(key: string): Promise<StorageObject | null>;
4
12
  delete(key: string): Promise<void>;
13
+ /** Public-URL serving only — throws when the adapter has no public base URL. */
5
14
  getUrl(key: string): string;
6
15
  }
16
+ /** What `createS3Adapter` actually returns — a StorageAdapter that can presign. */
17
+ export interface S3Adapter extends StorageAdapter {
18
+ /**
19
+ * Presigned GET URL for a private bucket. Cached until shortly before
20
+ * expiry and deduped in flight, so page loads with many images cost one
21
+ * signing pass per key.
22
+ */
23
+ getSignedUrl(key: string, expiresIn?: number): Promise<string>;
24
+ }
7
25
  export interface S3Config {
8
26
  /** Full endpoint URL, e.g. https://xxx.r2.cloudflarestorage.com */
9
27
  endpoint: string;
@@ -12,8 +30,12 @@ export interface S3Config {
12
30
  bucket: string;
13
31
  accessKeyId: string;
14
32
  secretAccessKey: string;
15
- /** Base public URL for getUrl(), e.g. https://pub-xxx.r2.dev */
16
- publicUrl: string;
33
+ /**
34
+ * Base public URL for getUrl(), e.g. https://pub-xxx.r2.dev. Optional —
35
+ * private buckets serve via `get()` (same-origin proxy) or `getSignedUrl()`
36
+ * instead, and `getUrl()` throws without it.
37
+ */
38
+ publicUrl?: string;
17
39
  /** Use path-style access (default: true for MinIO/R2, set false for Railway/AWS) */
18
40
  forcePathStyle?: boolean;
19
41
  }
@@ -32,18 +54,17 @@ export interface StoredMedia<TDerived = never> {
32
54
  */
33
55
  derived?: TDerived;
34
56
  }
35
- export interface MediaValidationError {
36
- isValid: false;
37
- error: string;
38
- }
39
- export interface MediaValidationOk {
40
- isValid: true;
41
- }
42
- export type MediaValidation = MediaValidationOk | MediaValidationError;
43
- export declare const DEFAULT_MAX_SIZE: number;
44
- export declare const DEFAULT_ALLOWED_TYPES: string[];
57
+ /** Machine-readable validation failure — map it to your own copy/i18n. */
58
+ export type ValidationErrorCode = 'empty' | 'file-too-large' | 'invalid-type';
45
59
  export interface ValidationResult {
46
60
  valid: boolean;
61
+ /** Set when `valid` is false. */
62
+ code?: ValidationErrorCode;
63
+ /** The limit that produced a 'file-too-large', for message interpolation. */
64
+ maxBytes?: number;
65
+ /** The allow-list that produced an 'invalid-type'. */
66
+ allowedTypes?: string[];
67
+ /** Human-readable English fallback; apps with i18n should map `code` instead. */
47
68
  error?: string;
48
69
  }
49
70
  /** What a `derive` hook receives — the image as uploaded, before any resizing. */
@@ -54,6 +75,10 @@ export interface DeriveSource {
54
75
  filename: string;
55
76
  /** The uploaded file's MIME type. */
56
77
  mimeType: string;
78
+ /** Storage prefix this upload went to, e.g. 'schools'. */
79
+ prefix: string;
80
+ /** The owning entity — what a failing hook needs to name in a log line. */
81
+ entityId: string;
57
82
  }
58
83
  /** The validation half of MediaConfig — all `validateImageFile` needs. */
59
84
  export interface ValidationConfig {
@@ -1,3 +1,2 @@
1
1
  // packages/svelte-media/src/lib/core/types.ts
2
- export const DEFAULT_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
3
- export const DEFAULT_ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
2
+ export {};
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { StorageAdapter, S3Config, StoredMedia, ImageSize, MediaConfig, ValidationResult, ValidationConfig, DeriveSource, } from './core/types.js';
2
- export { validateImageFile, generateMediaKey, getStorageKey, IMAGE_SIZES, DEFAULT_MAX_SIZE, DEFAULT_ALLOWED_TYPES, } from './core/media.js';
1
+ export type { StorageAdapter, S3Adapter, S3Config, StorageObject, StoredMedia, ImageSize, MediaConfig, ValidationResult, ValidationConfig, ValidationErrorCode, DeriveSource, } from './core/types.js';
2
+ export { validateImageFile, MediaValidationError, generateMediaKey, getStorageKey, variantKey, sizeForWidth, parseStorageKey, IMAGE_SIZES, SIZE_PREFIXES, SIZE_QUALITY, DEFAULT_MAX_SIZE, DEFAULT_ALLOWED_TYPES, } from './core/media.js';
3
3
  export { default as ImageUpload } from './components/ImageUpload.svelte';
4
4
  export { default as ImageGallery } from './components/ImageGallery.svelte';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Client-safe entry. The image pipeline and storage adapters need sharp,
2
2
  // node:fs and Buffer, so they live behind '@nomideusz/svelte-media/server' —
3
3
  // importing them here would break any browser bundle that touches a component.
4
- export { validateImageFile, generateMediaKey, getStorageKey, IMAGE_SIZES, DEFAULT_MAX_SIZE, DEFAULT_ALLOWED_TYPES, } from './core/media.js';
4
+ export { validateImageFile, MediaValidationError, generateMediaKey, getStorageKey, variantKey, sizeForWidth, parseStorageKey, IMAGE_SIZES, SIZE_PREFIXES, SIZE_QUALITY, DEFAULT_MAX_SIZE, DEFAULT_ALLOWED_TYPES, } from './core/media.js';
5
5
  export { default as ImageUpload } from './components/ImageUpload.svelte';
6
6
  export { default as ImageGallery } from './components/ImageGallery.svelte';
@@ -1,6 +1,13 @@
1
1
  export type { LocalConfig } from '../core/local-adapter.js';
2
2
  export { createS3Adapter } from '../core/adapter.js';
3
3
  export { createLocalAdapter } from '../core/local-adapter.js';
4
- export { processAndStore, deleteMedia, getMediaUrl } from '../core/process.js';
5
- export { validateImageFile, generateMediaKey, getStorageKey, IMAGE_SIZES, } from '../core/media.js';
4
+ export { adapterFromEnv } from '../core/env.js';
5
+ export type { EnvAdapterOptions, EnvSource } from '../core/env.js';
6
+ export { cachedImage, fetchImage, toWebp } from '../core/remote.js';
7
+ export type { CachedImageOptions, FetchImageOptions, ImageBytes } from '../core/remote.js';
8
+ export { processAndStore, deleteMedia, deleteMediaByKey, getMediaUrl } from '../core/process.js';
9
+ export { serveMedia } from '../core/serve.js';
10
+ export { createMediaStore } from '../core/store.js';
11
+ export type { MediaStore, MediaStoreConfig } from '../core/store.js';
12
+ export { validateImageFile, MediaValidationError, generateMediaKey, getStorageKey, variantKey, sizeForWidth, parseStorageKey, IMAGE_SIZES, SIZE_PREFIXES, SIZE_QUALITY, } from '../core/media.js';
6
13
  export type * from '../core/types.js';
@@ -1,6 +1,10 @@
1
1
  // Server-only entry: sharp, node:fs and Buffer live behind here.
2
2
  export { createS3Adapter } from '../core/adapter.js';
3
3
  export { createLocalAdapter } from '../core/local-adapter.js';
4
- export { processAndStore, deleteMedia, getMediaUrl } from '../core/process.js';
4
+ export { adapterFromEnv } from '../core/env.js';
5
+ export { cachedImage, fetchImage, toWebp } from '../core/remote.js';
6
+ export { processAndStore, deleteMedia, deleteMediaByKey, getMediaUrl } from '../core/process.js';
7
+ export { serveMedia } from '../core/serve.js';
8
+ export { createMediaStore } from '../core/store.js';
5
9
  // Re-exported so server code needs only one import.
6
- export { validateImageFile, generateMediaKey, getStorageKey, IMAGE_SIZES, } from '../core/media.js';
10
+ export { validateImageFile, MediaValidationError, generateMediaKey, getStorageKey, variantKey, sizeForWidth, parseStorageKey, IMAGE_SIZES, SIZE_PREFIXES, SIZE_QUALITY, } from '../core/media.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomideusz/svelte-media",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
4
4
  "description": "Image upload, processing, and S3-compatible storage for Svelte 5 apps.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -47,15 +47,15 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@sveltejs/adapter-auto": "^7.0.1",
50
- "@sveltejs/kit": "^2.69.1",
50
+ "@sveltejs/kit": "^2.70.2",
51
51
  "@sveltejs/package": "^2.5.8",
52
52
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
53
53
  "@types/node": "^25.9.4",
54
- "svelte": "^5.56.4",
54
+ "svelte": "^5.56.9",
55
55
  "svelte-check": "^4.7.1",
56
56
  "typescript": "^5.9.3",
57
57
  "vite": "^7.3.6",
58
- "vitest": "^4.1.9"
58
+ "vitest": "^4.1.10"
59
59
  },
60
60
  "keywords": [
61
61
  "svelte",