@nomideusz/svelte-media 0.2.0 → 0.5.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,86 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-08-09
4
+
5
+ ### Added
6
+ - **The read half of the pipeline.** `StorageAdapter` gains
7
+ `get(key): Promise<StorageObject | null>` (null = not found; real failures
8
+ reject), implemented by both adapters. Every deployment of this package was
9
+ serving from a private bucket, and both source apps had built parallel raw
10
+ S3 clients — three across the two apps — to work around the adapter being
11
+ write-only.
12
+ - **`getSignedUrl(key, expiresIn?)` on the S3 adapter** (the `S3Adapter`
13
+ type), with expiry-buffered caching and in-flight dedupe — absorbed from
14
+ thebest's hand-rolled presign layer. This is what the previously-dead
15
+ `@aws-sdk/s3-request-presigner` dependency is now for.
16
+ - **`serveMedia(adapter, key): Promise<Response>`** — the whole body of a
17
+ same-origin `/photos/[...key]`-style route: key validation (incl. `..`
18
+ traversal — one app's hand-written copy lacked the check), 404 on missing,
19
+ immutable cache headers.
20
+ - **`createMediaStore({ adapter, ...limits, derive? })`** — the per-app
21
+ binding factory both apps had hand-built (`photo-storage.ts`, `media.ts`),
22
+ returning bound `store/remove/get/serve/url/signedUrl/validate`. Env reading
23
+ stays in the app; the adapter is the seam.
24
+
25
+ ### Changed
26
+ - **Breaking for external adapter implementations:** `get` is now a required
27
+ `StorageAdapter` method. (Both in-repo apps consume the built-in adapters.)
28
+ - `S3Config.publicUrl` is now optional — both real deployments serve private
29
+ buckets and were passing dummy values to satisfy the field. `getUrl()`
30
+ throws without it.
31
+
32
+ ## 0.4.0 — 2026-08-09
33
+
34
+ ### Added
35
+ - **`SIZE_PREFIXES` and `SIZE_QUALITY` are now exported** (root and `/server`).
36
+ Both source apps had re-copied the prefix table by hand (one script copied the
37
+ quality values too) because the package kept them private — and the README had
38
+ already drifted from the code once (`lg_` vs `large_`). The variant naming is a
39
+ cross-app contract; now it has one home.
40
+ - **Joined-key helpers** for apps that persist the single original key rather
41
+ than the (prefix, entityId, filename) triple: `variantKey(key, size)`,
42
+ `parseStorageKey(key)` (null for keys that don't fit the layout, e.g. legacy
43
+ ids), and `deleteMediaByKey(adapter, key)` on `/server`. Also
44
+ `sizeForWidth(width?)` — the smallest pre-generated variant that still fills a
45
+ display width (moved in from yoga's hand-rolled copy).
46
+ - **Machine-readable validation.** `ValidationResult` gains
47
+ `code: 'empty' | 'file-too-large' | 'invalid-type'` plus `maxBytes` /
48
+ `allowedTypes`, and `processAndStore` now throws a typed
49
+ `MediaValidationError` carrying them — so a PL/EN/UK app maps `code` to its
50
+ own copy instead of surfacing the English `message`. Both apps had
51
+ re-implemented validation in 4 places purely to localize the strings.
52
+ The `empty` code also covers the zero-byte file every form action was
53
+ checking by hand.
54
+
55
+ ### Fixed
56
+ - `ImageUpload`'s declared `config` prop was never read, so the client-side
57
+ pre-validation it implied never ran. It now validates each file before
58
+ `onUpload` and routes failures to `onError`.
59
+ - README documented the large-variant prefix as `lg_`; the code writes
60
+ `large_`.
61
+
62
+ ### Changed
63
+ - `generateMediaKey()` no longer takes the ignored `originalName` parameter.
64
+ - The dead duplicate `DEFAULT_MAX_SIZE`/`DEFAULT_ALLOWED_TYPES` constants and
65
+ the unused `MediaValidation`/`MediaValidationOk` types in `core/types.ts` are
66
+ gone. (`MediaValidationError` is now the thrown error class instead of an
67
+ unused interface — technically a change to `/server`'s type surface.)
68
+
69
+ ## 0.3.0 — 2026-08-03
70
+
71
+ ### Added
72
+ - **`derive` / `onDeriveError` on `MediaConfig`, and `derived` on `StoredMedia`.**
73
+ A hook over the decoded bytes during processing, so anything computed from the
74
+ image — placeholder, blurhash, dominant colour, EXIF — happens while the bytes
75
+ are in memory rather than fetching them back later. `TDerived` is inferred from
76
+ the callback, so the package takes no dependency on what you derive.
77
+
78
+ The hook runs after every size is stored, and failures deliberately do not
79
+ propagate: by then the upload has succeeded, and rejecting would make the
80
+ caller retry a completed upload and orphan the objects already written.
81
+ `derived` is left undefined and `onDeriveError` is called.
82
+
83
+
3
84
  ## 0.2.0 — 2026-08-03
4
85
 
5
86
  ### Changed
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.
@@ -58,6 +60,32 @@ touches a database.
58
60
  **Output is always WebP**, whatever was uploaded; `generateMediaKey` names every
59
61
  file `<cuid2>.webp` and ignores the original extension.
60
62
 
63
+ ## Deriving extras at upload time
64
+
65
+ `processAndStore` already has the decoded bytes in memory. A `derive` hook lets
66
+ you compute something from them in the same pass — a placeholder, a blurhash, a
67
+ dominant colour, EXIF — instead of fetching the image back later:
68
+
69
+ ```ts
70
+ import { generatePlaceholder } from '@nomideusz/svelte-geometrize/node';
71
+
72
+ const stored = await processAndStore(storage, file, 'tours', tourId, {
73
+ derive: ({ buffer }) => generatePlaceholder(buffer),
74
+ onDeriveError: (err) => log.warn('placeholder failed', err),
75
+ });
76
+
77
+ stored.derived; // GeometrizePlaceholder | undefined — inferred from the hook
78
+ ```
79
+
80
+ The hook runs **after** every size is stored, and **failures do not propagate**:
81
+ by that point the upload has succeeded, and rejecting would make the caller
82
+ retry a completed upload — orphaning the objects already written. A failing
83
+ hook leaves `derived` undefined and calls `onDeriveError`. Wire that to your
84
+ logger, or the failure is silent.
85
+
86
+ The package takes no dependency on whatever you derive; `TDerived` is inferred
87
+ from the callback's return type.
88
+
61
89
  ## Sizes
62
90
 
63
91
  `processAndStore` always writes four variants:
@@ -67,7 +95,7 @@ file `<cuid2>.webp` and ignores the original extension.
67
95
  | `original` | unchanged | — | *(none)* |
68
96
  | `thumbnail` | 300×300 | `cover` | `thumb_` |
69
97
  | `medium` | 800×600 | `inside` | `med_` |
70
- | `large` | 1200×900 | `inside` | `lg_` |
98
+ | `large` | 1200×900 | `inside` | `large_` |
71
99
 
72
100
  `cover` crops to fill; `inside` fits within the box and preserves aspect ratio,
73
101
  so `medium` and `large` are upper bounds rather than exact dimensions. The table
@@ -126,6 +154,11 @@ await deleteMedia(storage, stored.prefix, stored.entityId, stored.filename);
126
154
 
127
155
  ## Components
128
156
 
157
+ > **Demo-grade.** Both production apps built on this package hand-roll their
158
+ > upload UI (multi-file queues, form-action submission, brand styling, i18n)
159
+ > and import only the server half. Treat these as starters to copy from, not
160
+ > primitives to build on.
161
+
129
162
  ```svelte
130
163
  <script lang="ts">
131
164
  import { ImageUpload, ImageGallery } from '@nomideusz/svelte-media';
@@ -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
  }
@@ -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, MediaConfig, 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: {
@@ -20,6 +20,35 @@ export declare const IMAGE_SIZES: {
20
20
  };
21
21
  export declare const SIZE_PREFIXES: Record<ImageSize, string>;
22
22
  export declare const SIZE_QUALITY: Record<ImageSize, number>;
23
- export declare function validateImageFile(file: File, config?: MediaConfig): ValidationResult;
24
- export declare function generateMediaKey(_originalName?: string): string;
23
+ export declare function validateImageFile(file: File, config?: ValidationConfig): ValidationResult;
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,16 +24,36 @@ 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
  }
@@ -41,3 +61,35 @@ export function getStorageKey(prefix, entityId, filename, size) {
41
61
  const sizePrefix = SIZE_PREFIXES[size];
42
62
  return `${prefix}/${entityId}/${sizePrefix}${filename}`;
43
63
  }
64
+ /**
65
+ * The variant key for a stored ORIGINAL key (`prefix/entityId/filename`) —
66
+ * for apps that persist the single joined key rather than the
67
+ * (prefix, entityId, filename) triple.
68
+ */
69
+ export function variantKey(key, size) {
70
+ const idx = key.lastIndexOf('/');
71
+ return `${key.slice(0, idx + 1)}${SIZE_PREFIXES[size]}${key.slice(idx + 1)}`;
72
+ }
73
+ /** The smallest pre-generated variant that still fills `width` display pixels. */
74
+ export function sizeForWidth(width) {
75
+ if (!width)
76
+ return 'medium';
77
+ if (width <= IMAGE_SIZES.thumbnail.width)
78
+ return 'thumbnail';
79
+ if (width <= IMAGE_SIZES.medium.width)
80
+ return 'medium';
81
+ return 'large';
82
+ }
83
+ /**
84
+ * Inverse of `getStorageKey` for an original key. Returns null for keys that
85
+ * don't fit the `prefix/entityId/filename` layout (e.g. legacy ids), so
86
+ * callers can treat those as "nothing to do".
87
+ */
88
+ export function parseStorageKey(key) {
89
+ const parts = key.split('/');
90
+ if (parts.length < 3)
91
+ return null;
92
+ const filename = parts.pop();
93
+ const entityId = parts.pop();
94
+ return { prefix: parts.join('/'), entityId, filename };
95
+ }
@@ -1,4 +1,10 @@
1
1
  import type { StorageAdapter, StoredMedia, ImageSize, MediaConfig } from './types.js';
2
- export declare function processAndStore(adapter: StorageAdapter, file: File, prefix: string, entityId: string, config?: MediaConfig): Promise<StoredMedia>;
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 = {
@@ -29,12 +29,36 @@ export async function processAndStore(adapter, file, prefix, entityId, config) {
29
29
  }
30
30
  // ponytail: existing objects stay JPEG under their old keys — URLs derive
31
31
  // from the stored filename, so old rows keep working without migration.
32
- return { filename, originalName: file.name, prefix, entityId, sizes };
32
+ // Runs last, so a throwing hook cannot orphan a half-written upload — and it
33
+ // is caught, because by this point the image is stored and rejecting would
34
+ // make the caller retry a completed upload.
35
+ let derived;
36
+ if (config?.derive) {
37
+ const source = { buffer, filename, mimeType: file.type };
38
+ try {
39
+ derived = await config.derive(source);
40
+ }
41
+ catch (error) {
42
+ config.onDeriveError?.(error, source);
43
+ }
44
+ }
45
+ return { filename, originalName: file.name, prefix, entityId, sizes, derived };
33
46
  }
34
47
  export async function deleteMedia(adapter, prefix, entityId, filename) {
35
48
  const keys = ['original', 'thumbnail', 'medium', 'large'].map((size) => getStorageKey(prefix, entityId, filename, size));
36
49
  await Promise.all(keys.map((key) => adapter.delete(key).catch(() => { })));
37
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
+ }
38
62
  export function getMediaUrl(adapter, prefix, entityId, filename, size = 'medium') {
39
63
  return adapter.getUrl(getStorageKey(prefix, entityId, filename, size));
40
64
  }
@@ -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,30 @@
1
+ import type { ImageSize, MediaConfig, StorageAdapter, StorageObject, StoredMedia, ValidationResult } from './types.js';
2
+ export interface MediaStoreConfig<TDerived = never> extends MediaConfig<TDerived> {
3
+ adapter: StorageAdapter;
4
+ }
5
+ /**
6
+ * The per-app binding — adapter, limits and derive hook bound once, so call
7
+ * sites pass only what varies per upload. Both source apps had hand-built this
8
+ * module (and one ended up configuring the same bucket two different ways);
9
+ * app #3 starts here instead.
10
+ *
11
+ * Env reading stays in the app: build the adapter from your own env names and
12
+ * hand it in.
13
+ */
14
+ export interface MediaStore<TDerived = never> {
15
+ adapter: StorageAdapter;
16
+ /** `processAndStore` with the bound adapter + config. */
17
+ store(file: File, prefix: string, entityId: string): Promise<StoredMedia<TDerived>>;
18
+ /** Deletes all variants by the joined original key; unparseable keys are a no-op. */
19
+ remove(key: string): Promise<void>;
20
+ get(key: string): Promise<StorageObject | null>;
21
+ /** `serveMedia` with the bound adapter — a complete same-origin route body. */
22
+ serve(key: string): Promise<Response>;
23
+ /** Public URL for a size variant (adapters with a publicUrl only). */
24
+ url(key: string, size?: ImageSize): string;
25
+ /** Presigned URL for a size variant — rejects unless the adapter is S3. */
26
+ signedUrl(key: string, size?: ImageSize, expiresIn?: number): Promise<string>;
27
+ /** `validateImageFile` with the bound limits. */
28
+ validate(file: File): ValidationResult;
29
+ }
30
+ export declare function createMediaStore<TDerived = never>(config: MediaStoreConfig<TDerived>): MediaStore<TDerived>;
@@ -0,0 +1,22 @@
1
+ import { validateImageFile, variantKey } from './media.js';
2
+ import { processAndStore, deleteMediaByKey } from './process.js';
3
+ import { serveMedia } from './serve.js';
4
+ export function createMediaStore(config) {
5
+ const { adapter, ...media } = config;
6
+ return {
7
+ adapter,
8
+ store: (file, prefix, entityId) => processAndStore(adapter, file, prefix, entityId, media),
9
+ remove: (key) => deleteMediaByKey(adapter, key),
10
+ get: (key) => adapter.get(key),
11
+ serve: (key) => serveMedia(adapter, key),
12
+ url: (key, size = 'original') => adapter.getUrl(variantKey(key, size)),
13
+ signedUrl: (key, size = 'original', expiresIn) => {
14
+ const s3 = adapter;
15
+ if (!s3.getSignedUrl) {
16
+ return Promise.reject(new Error('signedUrl needs an S3 adapter (getSignedUrl)'));
17
+ }
18
+ return s3.getSignedUrl(variantKey(key, size), expiresIn);
19
+ },
20
+ validate: (file) => validateImageFile(file, media),
21
+ };
22
+ }
@@ -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,12 +30,16 @@ 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
  }
20
- export interface StoredMedia {
42
+ export interface StoredMedia<TDerived = never> {
21
43
  filename: string;
22
44
  originalName: string;
23
45
  /** Storage prefix, e.g. 'tours' or 'avatars' */
@@ -26,22 +48,54 @@ export interface StoredMedia {
26
48
  entityId: string;
27
49
  /** Storage keys for each size */
28
50
  sizes: Record<ImageSize, string>;
51
+ /**
52
+ * Result of `config.derive`, when supplied. Undefined if no hook was given,
53
+ * or if it threw — see `MediaConfig.onDeriveError`.
54
+ */
55
+ derived?: TDerived;
29
56
  }
30
- export interface MediaValidationError {
31
- isValid: false;
32
- error: string;
33
- }
34
- export interface MediaValidationOk {
35
- isValid: true;
36
- }
37
- export type MediaValidation = MediaValidationOk | MediaValidationError;
38
- export declare const DEFAULT_MAX_SIZE: number;
39
- 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';
40
59
  export interface ValidationResult {
41
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. */
42
68
  error?: string;
43
69
  }
44
- export interface MediaConfig {
70
+ /** What a `derive` hook receives — the image as uploaded, before any resizing. */
71
+ export interface DeriveSource {
72
+ /** Original bytes, pre-resize, EXIF intact. */
73
+ buffer: Buffer;
74
+ /** The generated storage filename, e.g. `k7x2m9.webp`. */
75
+ filename: string;
76
+ /** The uploaded file's MIME type. */
77
+ mimeType: string;
78
+ }
79
+ /** The validation half of MediaConfig — all `validateImageFile` needs. */
80
+ export interface ValidationConfig {
45
81
  maxFileSize?: number;
46
82
  allowedTypes?: string[];
47
83
  }
84
+ export interface MediaConfig<TDerived = never> extends ValidationConfig {
85
+ /**
86
+ * Optional side-computation over the image, run once during processing while
87
+ * the bytes are already in hand — a placeholder, a blurhash, a dominant
88
+ * colour, EXIF. Whatever it returns is attached to the result as `derived`.
89
+ *
90
+ * Runs after every size has been stored, so a failure cannot orphan a
91
+ * half-written upload. Failures do not propagate: the upload has already
92
+ * succeeded and a derived extra must never invalidate it. `derived` is left
93
+ * undefined and `onDeriveError` is called.
94
+ */
95
+ derive?(source: DeriveSource): Promise<TDerived>;
96
+ /**
97
+ * Called when `derive` throws. The upload still succeeds. Without this the
98
+ * failure is silent, so wire it to your logger.
99
+ */
100
+ onDeriveError?(error: unknown, source: DeriveSource): void;
101
+ }
@@ -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, } 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,9 @@
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 { processAndStore, deleteMedia, deleteMediaByKey, getMediaUrl } from '../core/process.js';
5
+ export { serveMedia } from '../core/serve.js';
6
+ export { createMediaStore } from '../core/store.js';
7
+ export type { MediaStore, MediaStoreConfig } from '../core/store.js';
8
+ export { validateImageFile, MediaValidationError, generateMediaKey, getStorageKey, variantKey, sizeForWidth, parseStorageKey, IMAGE_SIZES, SIZE_PREFIXES, SIZE_QUALITY, } from '../core/media.js';
6
9
  export type * from '../core/types.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 { processAndStore, deleteMedia, getMediaUrl } from '../core/process.js';
4
+ export { processAndStore, deleteMedia, deleteMediaByKey, getMediaUrl } from '../core/process.js';
5
+ export { serveMedia } from '../core/serve.js';
6
+ export { createMediaStore } from '../core/store.js';
5
7
  // Re-exported so server code needs only one import.
6
- export { validateImageFile, generateMediaKey, getStorageKey, IMAGE_SIZES, } from '../core/media.js';
8
+ 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.2.0",
3
+ "version": "0.5.0",
4
4
  "description": "Image upload, processing, and S3-compatible storage for Svelte 5 apps.",
5
5
  "type": "module",
6
6
  "sideEffects": false,