@nomideusz/svelte-media 0.5.0 → 1.0.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,80 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 1.0.0 — the promise, sized to the proven surface. Two production apps
8
+ consume the validation, media store, env adapters, storage keys,
9
+ remote-image fetching and serving daily; 53 tests cover them; that API is
10
+ now stable, and anything that breaks a consumer costs a major.
11
+
12
+ **Breaking: `ImageUpload` and `ImageGallery` leave the public API.** In two
13
+ consuming apps neither was ever rendered, and this package's own changelog
14
+ records that surface shipping broken twice precisely because nothing
15
+ consumed it (a `config` prop that was never read; a build failure nobody
16
+ hit). A 1.0 must not promise stability on components without a single real
17
+ consumer. The files remain in-repo for the demo; if an app one day wants
18
+ them, re-exporting is an additive minor made against a component that
19
+ consumer actually exercises.
20
+
21
+ ## 0.6.0 — 2026-08-14
22
+
23
+ Second pass of the same job 0.5.0 started: the parts of "handling images" both
24
+ apps still owned move here, so an app keeps only what is actually its own —
25
+ bucket names, copy, and (for yoga) what a Google Places reference costs.
26
+
27
+ ### Added
28
+ - **`adapterFromEnv(env, options?)`** — the S3_*/`MEDIA_ROOT` wiring both apps
29
+ had hand-written, including the lazy singleton each carried. Reads
30
+ `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` / `S3_ACCESS_KEY_ID` /
31
+ `S3_SECRET_ACCESS_KEY`, falls back to a local adapter on `MEDIA_ROOT`, and
32
+ otherwise throws naming what is missing — a media surface that silently
33
+ no-ops is worse than a boot failure. Memoized per resolved config, so
34
+ per-request calls reuse one client and its signed-URL cache. Takes any
35
+ `Record<string, string | undefined>`, so no `$env` import enters the package.
36
+ - **`cachedImage(adapter, key, produce, options?)`** and
37
+ **`store.cached(...)`** — bucket-as-cache in front of a remote image source.
38
+ Stored copy first (the producer is never called on a hit), one producer call
39
+ per stampede, immutable headers, `onStoreError` for the write that would
40
+ otherwise silently make you pay twice. Extracted from yoga's Google Places
41
+ hero proxy, which had grown all of it inline.
42
+ - **`fetchImage(url, options?)`** — GET an image from a remote URL, rejecting
43
+ failed and non-image responses (an error page's HTML must never land in the
44
+ cache as a photo), transcoded to WebP by default.
45
+ - **`toWebp(bytes, quality?)`** — the transcode on its own; returns the input
46
+ untouched when sharp cannot read it, because an optimisation must never lose
47
+ an image you already hold.
48
+ - **`store.resolveUrl(key, size?, expiresIn?)`** + `basePath` on
49
+ `MediaStoreConfig` — presigned when the adapter can sign, else
50
+ `basePath/<variant key>` for the same-origin route. thebest had this fallback
51
+ written by hand across two modules.
52
+ - **`DeriveSource` gains `prefix` and `entityId`** (additive), so a `derive`
53
+ hook and its `onDeriveError` can name the entity they failed on. yoga was
54
+ closing over the id to log it.
55
+
56
+ ### Fixed
57
+ - **`getStorageKey` rejects filenames containing `/`, `\\` or `..`.** Filenames
58
+ are generated, but they round-trip through DB rows and form fields before
59
+ coming back to be deleted — thebest's delete action passes one straight from
60
+ `formData`, so a crafted value reached outside the entity's own folder.
61
+ `serveMedia` already guarded the read path; this is the write/delete one.
62
+
63
+ ### Changed
64
+ - `ImageBytes.body` is `Uint8Array<ArrayBuffer>` — the concrete view `Response`
65
+ and `Blob` accept.
66
+ - README caught up with 0.5.0: `get()` on the adapter interface, `serveMedia`,
67
+ `createMediaStore`, `getSignedUrl`.
68
+
69
+ ### Adopted by the apps
70
+ - yoga: `photo-storage.ts` is now bucket names + Polish copy + the placeholder
71
+ hook (−40 lines); `/api/photo` keeps the Google policy (budget guard, ref
72
+ self-heal, negative caches) and nothing else (−60 lines, no more sharp, no
73
+ more hand-rolled in-flight map).
74
+ - thebest: `media.ts` + `image-urls.ts` lost the adapter singleton, the
75
+ presign-or-fallback branch and the split-brain addressing; the tour upload
76
+ action no longer re-states the 15 MB limit it shares with `MEDIA_CONFIG`.
77
+
3
78
  ## 0.5.0 — 2026-08-09
4
79
 
5
80
  ### Added
package/README.md CHANGED
@@ -34,25 +34,37 @@ the seam.
34
34
 
35
35
  ## Quick Start
36
36
 
37
+ Bind the pipeline once per app, then call it from anywhere:
38
+
37
39
  ```ts
38
- import { createS3Adapter, processAndStore, getMediaUrl } from '@nomideusz/svelte-media/server';
39
-
40
- const storage = createS3Adapter({
41
- endpoint: process.env.S3_ENDPOINT!, // https://xxx.r2.cloudflarestorage.com
42
- region: 'auto', // 'auto' for R2, 'us-east-1' for AWS
43
- bucket: process.env.S3_BUCKET!,
44
- accessKeyId: process.env.S3_KEY!,
45
- secretAccessKey: process.env.S3_SECRET!,
46
- publicUrl: process.env.S3_PUBLIC_URL!, // https://pub-xxx.r2.dev
47
- forcePathStyle: true, // true for MinIO/R2, false for Railway/AWS
48
- });
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
+ ```
49
50
 
50
- const stored = await processAndStore(storage, file, 'tours', tourId);
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.
57
+
58
+ ```ts
59
+ const stored = await media().store(file, 'tours', tourId);
51
60
  // { filename, originalName, prefix, entityId, sizes: { original, thumbnail, medium, large } }
52
61
 
53
- const src = getMediaUrl(storage, stored.prefix, stored.entityId, stored.filename, 'medium');
62
+ const src = await media().resolveUrl(stored.sizes.original, 'medium');
54
63
  ```
55
64
 
65
+ Prefer the lower-level `createS3Adapter` / `processAndStore` when you want the
66
+ adapter built from something other than env vars.
67
+
56
68
  Store `stored` on your row — the helpers below all take
57
69
  `(prefix, entityId, filename)`, so keep those three. Nothing in the package
58
70
  touches a database.
@@ -103,25 +115,32 @@ is exported as `IMAGE_SIZES`.
103
115
 
104
116
  ## Storage adapters
105
117
 
106
- The seam is three methods:
118
+ The seam is four methods:
107
119
 
108
120
  ```ts
109
121
  interface StorageAdapter {
110
122
  put(key: string, buffer: Buffer, contentType: string): Promise<void>;
123
+ get(key: string): Promise<StorageObject | null>; // null = missing; rejects on real failures
111
124
  delete(key: string): Promise<void>;
112
- getUrl(key: string): string;
125
+ getUrl(key: string): string; // throws without a publicUrl
113
126
  }
114
127
  ```
115
128
 
116
- Two ship with the package:
129
+ Two ship with the package, plus the env-driven picker:
117
130
 
118
131
  ```ts
119
- import { createS3Adapter, createLocalAdapter } from '@nomideusz/svelte-media/server';
132
+ import {
133
+ adapterFromEnv, createS3Adapter, createLocalAdapter,
134
+ } from '@nomideusz/svelte-media/server';
120
135
 
121
- 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
122
138
  createLocalAdapter({ root: '/data/images' }); // writes under root, mkdir -p
123
139
  ```
124
140
 
141
+ `createS3Adapter` also returns `getSignedUrl(key, expiresIn?)` for private
142
+ buckets, with expiry-buffered caching and in-flight dedupe.
143
+
125
144
  Anything satisfying the interface works — write your own for a CDN or a test
126
145
  double.
127
146
 
@@ -152,6 +171,44 @@ import { deleteMedia } from '@nomideusz/svelte-media/server';
152
171
  await deleteMedia(storage, stored.prefix, stored.entityId, stored.filename);
153
172
  ```
154
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
+
155
212
  ## Components
156
213
 
157
214
  > **Demo-grade.** Both production apps built on this package hand-roll their
@@ -195,10 +252,18 @@ the same reason: it renders on the client, where the adapter cannot go.
195
252
 
196
253
  ```ts
197
254
  // ── @nomideusz/svelte-media/server ──
198
- createS3Adapter(config: S3Config): StorageAdapter
255
+ adapterFromEnv(env, options?): StorageAdapter // S3_* / MEDIA_ROOT, memoized
256
+ createS3Adapter(config: S3Config): S3Adapter
199
257
  createLocalAdapter(config: LocalConfig): StorageAdapter
258
+ createMediaStore({ adapter, basePath?, ...limits, derive? }): MediaStore
259
+ // .store .remove .get .serve .url .signedUrl .resolveUrl .cached .validate
200
260
  processAndStore(adapter, file, prefix, entityId, config?): Promise<StoredMedia>
201
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>
202
267
  getMediaUrl(adapter, prefix, entityId, filename, size = 'medium'): string
203
268
 
204
269
  // ── @nomideusz/svelte-media (client-safe) ──
@@ -211,8 +276,10 @@ ImageUpload, ImageGallery
211
276
 
212
277
  Key helpers are exported from both entries, so server code needs only one import.
213
278
 
214
- Types: `StorageAdapter`, `S3Config`, `LocalConfig`, `StoredMedia`, `ImageSize`,
215
- `MediaConfig`, `ValidationResult`.
279
+ Types: `StorageAdapter`, `S3Adapter`, `StorageObject`, `S3Config`,
280
+ `LocalConfig`, `MediaStore`, `MediaStoreConfig`, `StoredMedia`, `ImageSize`,
281
+ `MediaConfig`, `ValidationResult`, `ValidationErrorCode`, `DeriveSource`,
282
+ `ImageBytes`, `EnvAdapterOptions`.
216
283
 
217
284
  ## Development
218
285
 
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">let { images, getUrl, onDelete, size = "thumbnail" } = $props();
2
2
  let expanded = $state(null);
3
+ export {};
3
4
  </script>
4
5
 
5
6
  {#if images.length > 0}
@@ -1,36 +1,30 @@
1
1
  <script lang="ts">import { validateImageFile } from "../core/media.js";
2
- let {
3
- onUpload,
4
- onError,
5
- maxFiles = 1,
6
- accept = "image/jpeg,image/png,image/webp",
7
- config
8
- } = $props();
2
+ let { onUpload, onError, maxFiles = 1, accept = "image/jpeg,image/png,image/webp", config } = $props();
9
3
  let dragging = $state(false);
10
4
  let uploading = $state(false);
11
5
  let input;
12
6
  async function handleFiles(files) {
13
- if (!files || files.length === 0) return;
14
- uploading = true;
15
- try {
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
- }
22
- await onUpload(file);
23
- }
24
- } catch (e) {
25
- onError?.(e instanceof Error ? e.message : "Upload failed");
26
- } finally {
27
- uploading = false;
28
- }
7
+ if (!files || files.length === 0) return;
8
+ uploading = true;
9
+ try {
10
+ for (const file of Array.from(files).slice(0, maxFiles)) {
11
+ const validation = validateImageFile(file, config);
12
+ if (!validation.valid) {
13
+ onError?.(validation.error ?? "Invalid file");
14
+ continue;
15
+ }
16
+ await onUpload(file);
17
+ }
18
+ } catch (e) {
19
+ onError?.(e instanceof Error ? e.message : "Upload failed");
20
+ } finally {
21
+ uploading = false;
22
+ }
29
23
  }
30
24
  function onDrop(e) {
31
- e.preventDefault();
32
- dragging = false;
33
- handleFiles(e.dataTransfer?.files ?? null);
25
+ e.preventDefault();
26
+ dragging = false;
27
+ handleFiles(e.dataTransfer?.files ?? null);
34
28
  }
35
29
  </script>
36
30
 
@@ -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
+ }
@@ -58,6 +58,13 @@ export function generateMediaKey() {
58
58
  return `${createId()}.webp`;
59
59
  }
60
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
+ }
61
68
  const sizePrefix = SIZE_PREFIXES[size];
62
69
  return `${prefix}/${entityId}/${sizePrefix}${filename}`;
63
70
  }
@@ -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
  }
@@ -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,109 @@
1
+ // Caching images that come from somewhere else — a paid photo API, a partner
2
+ // CDN, a scraper. Server-only (sharp).
3
+ //
4
+ // kit 3 claims any file named `remote.*` as a remote-functions module
5
+ // (pattern /[/.]remote\.[^/]+$/) and rejects its plain exports — hence
6
+ // `remote-images`, not `remote`.
7
+ //
8
+ // Extracted from yoga's Google Places hero proxy, which was paying Google per
9
+ // origin miss until it grew a bucket cache, a stampede lock and a transcode
10
+ // step around it. All three are storage plumbing, not Places knowledge: what
11
+ // stays in the app is which URL to call, what it costs and when to give up.
12
+ import sharp from 'sharp';
13
+ // Cached objects are keyed by content and never rewritten in place, so the
14
+ // bytes behind a URL never change — same header serveMedia sends.
15
+ const IMMUTABLE = 'public, max-age=31536000, immutable';
16
+ /**
17
+ * Re-encode to WebP (~30-50% smaller than the JPEG most photo APIs serve).
18
+ * Returns the input untouched when sharp cannot read it — a transcode is an
19
+ * optimisation, never a reason to lose an image we already hold.
20
+ */
21
+ export async function toWebp(bytes, quality = 80) {
22
+ try {
23
+ const webp = await sharp(Buffer.from(bytes)).webp({ quality }).toBuffer();
24
+ return { body: new Uint8Array(webp), contentType: 'image/webp' };
25
+ }
26
+ catch {
27
+ return { body: bytes, contentType: 'application/octet-stream' };
28
+ }
29
+ }
30
+ /**
31
+ * GET an image from a remote URL, transcoded to WebP by default.
32
+ * `null` when the response fails or isn't an image — an error page's HTML must
33
+ * never reach the cache as if it were a photo.
34
+ */
35
+ export async function fetchImage(url, options = {}) {
36
+ const { webp = 80, ...init } = options;
37
+ const res = await fetch(url, { redirect: 'follow', ...init });
38
+ if (!res.ok)
39
+ return null;
40
+ const contentType = res.headers.get('content-type') ?? '';
41
+ if (!contentType.startsWith('image/'))
42
+ return null;
43
+ const bytes = new Uint8Array(await res.arrayBuffer());
44
+ if (webp === false)
45
+ return { body: bytes, contentType };
46
+ const encoded = await toWebp(bytes, webp);
47
+ // sharp failed — keep the upstream type rather than toWebp's octet-stream.
48
+ return encoded.contentType === 'image/webp' ? encoded : { body: bytes, contentType };
49
+ }
50
+ // Per-adapter, so two stores in one process cannot collide on a shared key.
51
+ const inflight = new WeakMap();
52
+ /**
53
+ * Serve `key` from storage, producing it once on the first miss.
54
+ *
55
+ * Cheapest path first: a stored copy is streamed without calling `produce` at
56
+ * all. Concurrent misses for the same key share one `produce` call — a CDN
57
+ * stampede across PoPs, or a crawler, otherwise buys one copy each.
58
+ *
59
+ * `null` means `produce` had nothing (the caller decides: 404, placeholder,
60
+ * fall through). A storage read that throws is treated as a miss: an
61
+ * unreachable bucket must not blank an image it could still fetch.
62
+ */
63
+ export async function cachedImage(adapter, key, produce, options = {}) {
64
+ const stored = await adapter.get(key).catch(() => null);
65
+ if (stored) {
66
+ return new Response(stored.body, {
67
+ headers: {
68
+ 'Content-Type': stored.contentType,
69
+ ...(stored.contentLength != null
70
+ ? { 'Content-Length': String(stored.contentLength) }
71
+ : {}),
72
+ 'Cache-Control': IMMUTABLE,
73
+ },
74
+ });
75
+ }
76
+ let pending = inflight.get(adapter);
77
+ if (!pending) {
78
+ pending = new Map();
79
+ inflight.set(adapter, pending);
80
+ }
81
+ let work = pending.get(key);
82
+ if (!work) {
83
+ work = (async () => {
84
+ const image = await produce();
85
+ if (!image)
86
+ return null;
87
+ // Awaited: a silent write failure means paying for these bytes again on
88
+ // the next request, which is the whole point of the cache.
89
+ try {
90
+ await adapter.put(key, Buffer.from(image.body), image.contentType);
91
+ }
92
+ catch (error) {
93
+ options.onStoreError?.(error, key);
94
+ }
95
+ return image;
96
+ })().finally(() => pending.delete(key));
97
+ pending.set(key, work);
98
+ }
99
+ const image = await work;
100
+ if (!image)
101
+ return null;
102
+ return new Response(image.body, {
103
+ headers: {
104
+ 'Content-Type': image.contentType,
105
+ 'Content-Length': String(image.body.byteLength),
106
+ 'Cache-Control': IMMUTABLE,
107
+ },
108
+ });
109
+ }
@@ -1,6 +1,13 @@
1
1
  import type { ImageSize, MediaConfig, StorageAdapter, StorageObject, StoredMedia, ValidationResult } from './types.js';
2
+ import { type CachedImageOptions, type ImageBytes } from './remote-images.js';
2
3
  export interface MediaStoreConfig<TDerived = never> extends MediaConfig<TDerived> {
3
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;
4
11
  }
5
12
  /**
6
13
  * The per-app binding — adapter, limits and derive hook bound once, so call
@@ -24,6 +31,17 @@ export interface MediaStore<TDerived = never> {
24
31
  url(key: string, size?: ImageSize): string;
25
32
  /** Presigned URL for a size variant — rejects unless the adapter is S3. */
26
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>;
27
45
  /** `validateImageFile` with the bound limits. */
28
46
  validate(file: File): ValidationResult;
29
47
  }
@@ -1,8 +1,10 @@
1
1
  import { validateImageFile, variantKey } from './media.js';
2
2
  import { processAndStore, deleteMediaByKey } from './process.js';
3
3
  import { serveMedia } from './serve.js';
4
+ import { cachedImage } from './remote-images.js';
4
5
  export function createMediaStore(config) {
5
- const { adapter, ...media } = config;
6
+ const { adapter, basePath, ...media } = config;
7
+ const signer = adapter.getSignedUrl?.bind(adapter);
6
8
  return {
7
9
  adapter,
8
10
  store: (file, prefix, entityId) => processAndStore(adapter, file, prefix, entityId, media),
@@ -11,12 +13,21 @@ export function createMediaStore(config) {
11
13
  serve: (key) => serveMedia(adapter, key),
12
14
  url: (key, size = 'original') => adapter.getUrl(variantKey(key, size)),
13
15
  signedUrl: (key, size = 'original', expiresIn) => {
14
- const s3 = adapter;
15
- if (!s3.getSignedUrl) {
16
+ if (!signer) {
16
17
  return Promise.reject(new Error('signedUrl needs an S3 adapter (getSignedUrl)'));
17
18
  }
18
- return s3.getSignedUrl(variantKey(key, size), expiresIn);
19
+ return signer(variantKey(key, size), expiresIn);
19
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),
20
31
  validate: (file) => validateImageFile(file, media),
21
32
  };
22
33
  }
@@ -1,3 +1,4 @@
1
+ /// <reference types="node" preserve="true" />
1
2
  export type ImageSize = 'original' | 'thumbnail' | 'medium' | 'large';
2
3
  /** What `StorageAdapter.get` resolves for an existing object. */
3
4
  export interface StorageObject {
@@ -75,6 +76,10 @@ export interface DeriveSource {
75
76
  filename: string;
76
77
  /** The uploaded file's MIME type. */
77
78
  mimeType: string;
79
+ /** Storage prefix this upload went to, e.g. 'schools'. */
80
+ prefix: string;
81
+ /** The owning entity — what a failing hook needs to name in a log line. */
82
+ entityId: string;
78
83
  }
79
84
  /** The validation half of MediaConfig — all `validateImageFile` needs. */
80
85
  export interface ValidationConfig {
@@ -1,2 +1,3 @@
1
1
  // packages/svelte-media/src/lib/core/types.ts
2
+ /// <reference types="node" preserve="true" />
2
3
  export {};
package/dist/index.d.ts CHANGED
@@ -1,4 +1,2 @@
1
1
  export type { StorageAdapter, S3Adapter, S3Config, StorageObject, StoredMedia, ImageSize, MediaConfig, ValidationResult, ValidationConfig, ValidationErrorCode, DeriveSource, } from './core/types.js';
2
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
- export { default as ImageUpload } from './components/ImageUpload.svelte';
4
- export { default as ImageGallery } from './components/ImageGallery.svelte';
package/dist/index.js CHANGED
@@ -2,5 +2,7 @@
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
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
- export { default as ImageUpload } from './components/ImageUpload.svelte';
6
- export { default as ImageGallery } from './components/ImageGallery.svelte';
5
+ // ImageUpload / ImageGallery left the public API at 1.0: in two consuming
6
+ // apps neither was ever rendered, and this package's own changelog records
7
+ // the surface shipping broken twice precisely because nothing consumed it.
8
+ // The files remain for the demo; re-exporting later is an additive minor.
@@ -1,6 +1,10 @@
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 { adapterFromEnv } from '../core/env.js';
5
+ export type { EnvAdapterOptions, EnvSource } from '../core/env.js';
6
+ export { cachedImage, fetchImage, toWebp } from '../core/remote-images.js';
7
+ export type { CachedImageOptions, FetchImageOptions, ImageBytes } from '../core/remote-images.js';
4
8
  export { processAndStore, deleteMedia, deleteMediaByKey, getMediaUrl } from '../core/process.js';
5
9
  export { serveMedia } from '../core/serve.js';
6
10
  export { createMediaStore } from '../core/store.js';
@@ -1,6 +1,8 @@
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 { adapterFromEnv } from '../core/env.js';
5
+ export { cachedImage, fetchImage, toWebp } from '../core/remote-images.js';
4
6
  export { processAndStore, deleteMedia, deleteMediaByKey, getMediaUrl } from '../core/process.js';
5
7
  export { serveMedia } from '../core/serve.js';
6
8
  export { createMediaStore } from '../core/store.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomideusz/svelte-media",
3
- "version": "0.5.0",
3
+ "version": "1.0.0",
4
4
  "description": "Image upload, processing, and S3-compatible storage for Svelte 5 apps.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -24,19 +24,12 @@
24
24
  "!dist/**/*.test.*",
25
25
  "!dist/**/*.spec.*"
26
26
  ],
27
- "scripts": {
28
- "dev": "vite dev",
29
- "build": "vite build",
30
- "package": "svelte-kit sync && svelte-package",
31
- "prepublishOnly": "npm run package",
32
- "preview": "vite preview",
33
- "prepare": "svelte-kit sync || echo ''",
34
- "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
35
- "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
36
- "test": "vitest run --passWithNoTests",
37
- "test:watch": "vitest"
27
+ "imports": {
28
+ "#lib": "./src/lib",
29
+ "#lib/*": "./src/lib/*"
38
30
  },
39
31
  "peerDependencies": {
32
+ "@types/node": ">=20",
40
33
  "svelte": "^5.0.0"
41
34
  },
42
35
  "dependencies": {
@@ -46,16 +39,17 @@
46
39
  "sharp": "^0.34.5"
47
40
  },
48
41
  "devDependencies": {
49
- "@sveltejs/adapter-auto": "^7.0.1",
50
- "@sveltejs/kit": "^2.69.1",
51
- "@sveltejs/package": "^2.5.8",
52
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
42
+ "@sveltejs/adapter-auto": "8.0.0-next.3",
43
+ "@sveltejs/adapter-vercel": "7.0.0-next.6",
44
+ "@sveltejs/kit": "3.0.0-next.23",
45
+ "@sveltejs/package": "3.0.0-next.7",
46
+ "@sveltejs/vite-plugin-svelte": "^7.3.0",
53
47
  "@types/node": "^25.9.4",
54
- "svelte": "^5.56.4",
48
+ "svelte": "^5.56.9",
55
49
  "svelte-check": "^4.7.1",
56
- "typescript": "^5.9.3",
57
- "vite": "^7.3.6",
58
- "vitest": "^4.1.9"
50
+ "typescript": "^6.0.3",
51
+ "vite": "^8.2.1",
52
+ "vitest": "^4.1.10"
59
53
  },
60
54
  "keywords": [
61
55
  "svelte",
@@ -69,5 +63,20 @@
69
63
  "type": "git",
70
64
  "url": "https://github.com/nomideusz/svelte-media"
71
65
  },
72
- "homepage": "https://svelte-media-gamma.vercel.app/"
73
- }
66
+ "homepage": "https://svelte-media-gamma.vercel.app/",
67
+ "peerDependenciesMeta": {
68
+ "@types/node": {
69
+ "optional": true
70
+ }
71
+ },
72
+ "scripts": {
73
+ "dev": "vite dev",
74
+ "build": "vite build",
75
+ "package": "svelte-kit sync && svelte-package",
76
+ "preview": "vite preview",
77
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
78
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
79
+ "test": "vitest run --passWithNoTests",
80
+ "test:watch": "vitest"
81
+ }
82
+ }