@nomideusz/svelte-media 0.5.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 +57 -0
- package/README.md +88 -21
- package/dist/core/env.d.ts +23 -0
- package/dist/core/env.js +59 -0
- package/dist/core/media.js +7 -0
- package/dist/core/process.js +1 -1
- package/dist/core/remote.d.ts +42 -0
- package/dist/core/remote.js +105 -0
- package/dist/core/store.d.ts +18 -0
- package/dist/core/store.js +15 -4
- package/dist/core/types.d.ts +4 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +2 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
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
|
+
|
|
3
60
|
## 0.5.0 — 2026-08-09
|
|
4
61
|
|
|
5
62
|
### 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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 {
|
|
132
|
+
import {
|
|
133
|
+
adapterFromEnv, createS3Adapter, createLocalAdapter,
|
|
134
|
+
} from '@nomideusz/svelte-media/server';
|
|
120
135
|
|
|
121
|
-
|
|
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
|
-
|
|
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`, `
|
|
215
|
-
`
|
|
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
|
|
|
@@ -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;
|
package/dist/core/env.js
ADDED
|
@@ -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
|
+
}
|
package/dist/core/media.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/process.js
CHANGED
|
@@ -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,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
|
+
}
|
package/dist/core/store.d.ts
CHANGED
|
@@ -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.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
|
}
|
package/dist/core/store.js
CHANGED
|
@@ -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.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
|
-
|
|
15
|
-
if (!s3.getSignedUrl) {
|
|
16
|
+
if (!signer) {
|
|
16
17
|
return Promise.reject(new Error('signedUrl needs an S3 adapter (getSignedUrl)'));
|
|
17
18
|
}
|
|
18
|
-
return
|
|
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
|
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -75,6 +75,10 @@ export interface DeriveSource {
|
|
|
75
75
|
filename: string;
|
|
76
76
|
/** The uploaded file's MIME type. */
|
|
77
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;
|
|
78
82
|
}
|
|
79
83
|
/** The validation half of MediaConfig — all `validateImageFile` needs. */
|
|
80
84
|
export interface ValidationConfig {
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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.js';
|
|
7
|
+
export type { CachedImageOptions, FetchImageOptions, ImageBytes } from '../core/remote.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';
|
package/dist/server/index.js
CHANGED
|
@@ -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.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.
|
|
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.
|
|
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.
|
|
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.
|
|
58
|
+
"vitest": "^4.1.10"
|
|
59
59
|
},
|
|
60
60
|
"keywords": [
|
|
61
61
|
"svelte",
|