@nomideusz/svelte-media 0.1.1 → 0.3.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,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-08-03
4
+
5
+ ### Added
6
+ - **`derive` / `onDeriveError` on `MediaConfig`, and `derived` on `StoredMedia`.**
7
+ A hook over the decoded bytes during processing, so anything computed from the
8
+ image — placeholder, blurhash, dominant colour, EXIF — happens while the bytes
9
+ are in memory rather than fetching them back later. `TDerived` is inferred from
10
+ the callback, so the package takes no dependency on what you derive.
11
+
12
+ The hook runs after every size is stored, and failures deliberately do not
13
+ propagate: by then the upload has succeeded, and rejecting would make the
14
+ caller retry a completed upload and orphan the objects already written.
15
+ `derived` is left undefined and `onDeriveError` is called.
16
+
17
+
18
+ ## 0.2.0 — 2026-08-03
19
+
20
+ ### Changed
21
+ - **Breaking: the package is split into two entry points.** Storage adapters,
22
+ `processAndStore`, `deleteMedia` and `getMediaUrl` move to
23
+ `@nomideusz/svelte-media/server`; components, `validateImageFile`, the key
24
+ helpers, `IMAGE_SIZES` and the types stay on the root import.
25
+
26
+ Everything was previously behind one entry, and `core/process.ts` imported
27
+ `sharp` while `core/local-adapter.ts` imported `node:fs` and `node:path` at
28
+ module level. Any browser bundle that touched the package pulled those in and
29
+ failed to build — which means `ImageUpload` and `ImageGallery`, the two
30
+ exports that exist to be used from a component, could not be. The bug went
31
+ unnoticed because both apps in the source monorepo import only from server
32
+ files.
33
+
34
+ Migration: append `/server` to imports of the adapters or the pipeline. Key
35
+ helpers are re-exported from `/server` too, so a server file still needs one
36
+ import.
37
+
38
+ ### Added
39
+ - A real demo at https://svelte-media-gamma.vercel.app/ — the components running
40
+ live, `validateImageFile` against files you choose, and the storage-key layout
41
+ for a prefix and entity you type. The server pipeline is shown as code, since
42
+ it cannot run in a browser.
43
+
44
+
3
45
  Backfilled 2026-08-02 from git history. Entries before that date are
4
46
  reconstructed from commits, so they record what changed rather than a release
5
47
  that was tagged at the time. 0.1.0 was published manually — trusted publishing
package/README.md CHANGED
@@ -10,9 +10,18 @@ pluggable storage adapter — S3-compatible or local disk.
10
10
  pnpm add @nomideusz/svelte-media
11
11
  ```
12
12
 
13
- > Requires Svelte 5 (`^5.0.0`). The processing path is server-only: it uses
14
- > `sharp` and Node `Buffer`. Import `processAndStore` from server code
15
- > (`+page.server.ts`, a route handler, a script) never from a component.
13
+ > Requires Svelte 5 (`^5.0.0`).
14
+
15
+ The package has two entry points, because half of it cannot run in a browser:
16
+
17
+ | Import | Contains | Where |
18
+ |---|---|---|
19
+ | `@nomideusz/svelte-media` | components, `validateImageFile`, key helpers, `IMAGE_SIZES`, types | anywhere |
20
+ | `@nomideusz/svelte-media/server` | storage adapters, `processAndStore`, `deleteMedia`, `getMediaUrl` | server only |
21
+
22
+ The pipeline needs `sharp`, `node:fs` and `Buffer`. Keeping it behind `/server`
23
+ is what lets a component import `ImageUpload` without a bundler dragging those
24
+ into the browser build.
16
25
 
17
26
  ## Why
18
27
 
@@ -24,7 +33,7 @@ the seam.
24
33
  ## Quick Start
25
34
 
26
35
  ```ts
27
- import { createS3Adapter, processAndStore, getMediaUrl } from '@nomideusz/svelte-media';
36
+ import { createS3Adapter, processAndStore, getMediaUrl } from '@nomideusz/svelte-media/server';
28
37
 
29
38
  const storage = createS3Adapter({
30
39
  endpoint: process.env.S3_ENDPOINT!, // https://xxx.r2.cloudflarestorage.com
@@ -49,6 +58,32 @@ touches a database.
49
58
  **Output is always WebP**, whatever was uploaded; `generateMediaKey` names every
50
59
  file `<cuid2>.webp` and ignores the original extension.
51
60
 
61
+ ## Deriving extras at upload time
62
+
63
+ `processAndStore` already has the decoded bytes in memory. A `derive` hook lets
64
+ you compute something from them in the same pass — a placeholder, a blurhash, a
65
+ dominant colour, EXIF — instead of fetching the image back later:
66
+
67
+ ```ts
68
+ import { generatePlaceholder } from '@nomideusz/svelte-geometrize/node';
69
+
70
+ const stored = await processAndStore(storage, file, 'tours', tourId, {
71
+ derive: ({ buffer }) => generatePlaceholder(buffer),
72
+ onDeriveError: (err) => log.warn('placeholder failed', err),
73
+ });
74
+
75
+ stored.derived; // GeometrizePlaceholder | undefined — inferred from the hook
76
+ ```
77
+
78
+ The hook runs **after** every size is stored, and **failures do not propagate**:
79
+ by that point the upload has succeeded, and rejecting would make the caller
80
+ retry a completed upload — orphaning the objects already written. A failing
81
+ hook leaves `derived` undefined and calls `onDeriveError`. Wire that to your
82
+ logger, or the failure is silent.
83
+
84
+ The package takes no dependency on whatever you derive; `TDerived` is inferred
85
+ from the callback's return type.
86
+
52
87
  ## Sizes
53
88
 
54
89
  `processAndStore` always writes four variants:
@@ -79,7 +114,7 @@ interface StorageAdapter {
79
114
  Two ship with the package:
80
115
 
81
116
  ```ts
82
- import { createS3Adapter, createLocalAdapter } from '@nomideusz/svelte-media';
117
+ import { createS3Adapter, createLocalAdapter } from '@nomideusz/svelte-media/server';
83
118
 
84
119
  createS3Adapter({ /* S3Config, above */ }); // R2, MinIO, Tigris, AWS
85
120
  createLocalAdapter({ root: '/data/images' }); // writes under root, mkdir -p
@@ -109,7 +144,7 @@ is only needed to reject early and report a friendlier message.
109
144
  ## Deleting
110
145
 
111
146
  ```ts
112
- import { deleteMedia } from '@nomideusz/svelte-media';
147
+ import { deleteMedia } from '@nomideusz/svelte-media/server';
113
148
 
114
149
  // Removes all four sizes; individual failures are swallowed
115
150
  await deleteMedia(storage, stored.prefix, stored.entityId, stored.filename);
@@ -152,23 +187,23 @@ the same reason: it renders on the client, where the adapter cannot go.
152
187
  ## API
153
188
 
154
189
  ```ts
155
- // Adapters
190
+ // ── @nomideusz/svelte-media/server ──
156
191
  createS3Adapter(config: S3Config): StorageAdapter
157
192
  createLocalAdapter(config: LocalConfig): StorageAdapter
158
-
159
- // Pipeline
160
193
  processAndStore(adapter, file, prefix, entityId, config?): Promise<StoredMedia>
161
194
  deleteMedia(adapter, prefix, entityId, filename): Promise<void>
162
195
  getMediaUrl(adapter, prefix, entityId, filename, size = 'medium'): string
163
- getStorageKey(prefix, entityId, filename, size): string // `${prefix}/${entityId}/${sizePrefix}${filename}`
196
+
197
+ // ── @nomideusz/svelte-media (client-safe) ──
164
198
  validateImageFile(file, config?): ValidationResult
165
199
  generateMediaKey(): string // `<cuid2>.webp`
200
+ getStorageKey(prefix, entityId, filename, size): string // `${prefix}/${entityId}/${sizePrefix}${filename}`
166
201
  IMAGE_SIZES
167
-
168
- // Components
169
202
  ImageUpload, ImageGallery
170
203
  ```
171
204
 
205
+ Key helpers are exported from both entries, so server code needs only one import.
206
+
172
207
  Types: `StorageAdapter`, `S3Config`, `LocalConfig`, `StoredMedia`, `ImageSize`,
173
208
  `MediaConfig`, `ValidationResult`.
174
209
 
@@ -0,0 +1,25 @@
1
+ import type { ImageSize, ValidationConfig, ValidationResult } from './types.js';
2
+ export declare const DEFAULT_MAX_SIZE: number;
3
+ export declare const DEFAULT_ALLOWED_TYPES: string[];
4
+ export declare const IMAGE_SIZES: {
5
+ thumbnail: {
6
+ width: number;
7
+ height: number;
8
+ fit: "cover";
9
+ };
10
+ medium: {
11
+ width: number;
12
+ height: number;
13
+ fit: "inside";
14
+ };
15
+ large: {
16
+ width: number;
17
+ height: number;
18
+ fit: "inside";
19
+ };
20
+ };
21
+ export declare const SIZE_PREFIXES: Record<ImageSize, string>;
22
+ export declare const SIZE_QUALITY: Record<ImageSize, number>;
23
+ export declare function validateImageFile(file: File, config?: ValidationConfig): ValidationResult;
24
+ export declare function generateMediaKey(_originalName?: string): string;
25
+ export declare function getStorageKey(prefix: string, entityId: string, filename: string, size: ImageSize): string;
@@ -0,0 +1,43 @@
1
+ // Client-safe half of the package: validation, naming and key layout.
2
+ // Deliberately free of sharp, node:fs and Buffer so components and browser code
3
+ // can import it — see core/process.ts for the server pipeline.
4
+ import { createId } from '@paralleldrive/cuid2';
5
+ export const DEFAULT_MAX_SIZE = 5 * 1024 * 1024;
6
+ export const DEFAULT_ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
7
+ export const IMAGE_SIZES = {
8
+ thumbnail: { width: 300, height: 300, fit: 'cover' },
9
+ medium: { width: 800, height: 600, fit: 'inside' },
10
+ large: { width: 1200, height: 900, fit: 'inside' },
11
+ };
12
+ export const SIZE_PREFIXES = {
13
+ original: '',
14
+ thumbnail: 'thumb_',
15
+ medium: 'med_',
16
+ large: 'large_',
17
+ };
18
+ export const SIZE_QUALITY = {
19
+ original: 95,
20
+ thumbnail: 80,
21
+ medium: 85,
22
+ large: 90,
23
+ };
24
+ export function validateImageFile(file, config) {
25
+ const maxSize = config?.maxFileSize ?? DEFAULT_MAX_SIZE;
26
+ const allowed = config?.allowedTypes ?? DEFAULT_ALLOWED_TYPES;
27
+ if (file.size > maxSize) {
28
+ const mb = Math.round(maxSize / 1024 / 1024);
29
+ return { valid: false, error: `File too large (max ${mb}MB)` };
30
+ }
31
+ if (!allowed.includes(file.type)) {
32
+ return { valid: false, error: `Invalid file type. Allowed: JPEG, PNG, WebP` };
33
+ }
34
+ return { valid: true };
35
+ }
36
+ export function generateMediaKey(_originalName) {
37
+ // All processed output is WebP regardless of the uploaded format.
38
+ return `${createId()}.webp`;
39
+ }
40
+ export function getStorageKey(prefix, entityId, filename, size) {
41
+ const sizePrefix = SIZE_PREFIXES[size];
42
+ return `${prefix}/${entityId}/${sizePrefix}${filename}`;
43
+ }
@@ -1,24 +1,4 @@
1
- import type { StorageAdapter, StoredMedia, ImageSize, MediaConfig, ValidationResult } from './types.js';
2
- export declare const IMAGE_SIZES: {
3
- thumbnail: {
4
- width: number;
5
- height: number;
6
- fit: "cover";
7
- };
8
- medium: {
9
- width: number;
10
- height: number;
11
- fit: "inside";
12
- };
13
- large: {
14
- width: number;
15
- height: number;
16
- fit: "inside";
17
- };
18
- };
19
- export declare function validateImageFile(file: File, config?: MediaConfig): ValidationResult;
20
- export declare function generateMediaKey(_originalName?: string): string;
21
- export declare function getStorageKey(prefix: string, entityId: string, filename: string, size: ImageSize): string;
22
- export declare function processAndStore(adapter: StorageAdapter, file: File, prefix: string, entityId: string, config?: MediaConfig): Promise<StoredMedia>;
1
+ import type { StorageAdapter, StoredMedia, ImageSize, MediaConfig } from './types.js';
2
+ export declare function processAndStore<TDerived = never>(adapter: StorageAdapter, file: File, prefix: string, entityId: string, config?: MediaConfig<TDerived>): Promise<StoredMedia<TDerived>>;
23
3
  export declare function deleteMedia(adapter: StorageAdapter, prefix: string, entityId: string, filename: string): Promise<void>;
24
4
  export declare function getMediaUrl(adapter: StorageAdapter, prefix: string, entityId: string, filename: string, size?: ImageSize): string;
@@ -1,44 +1,8 @@
1
+ // Server-only pipeline: needs sharp and Node's Buffer. Importing this from a
2
+ // browser bundle will fail — the client-safe helpers live in core/media.ts and
3
+ // are re-exported from the package root.
1
4
  import sharp from 'sharp';
2
- import { createId } from '@paralleldrive/cuid2';
3
- const DEFAULT_MAX_SIZE = 5 * 1024 * 1024;
4
- const DEFAULT_ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
5
- export const IMAGE_SIZES = {
6
- thumbnail: { width: 300, height: 300, fit: 'cover' },
7
- medium: { width: 800, height: 600, fit: 'inside' },
8
- large: { width: 1200, height: 900, fit: 'inside' },
9
- };
10
- const SIZE_PREFIXES = {
11
- original: '',
12
- thumbnail: 'thumb_',
13
- medium: 'med_',
14
- large: 'large_',
15
- };
16
- const SIZE_QUALITY = {
17
- original: 95,
18
- thumbnail: 80,
19
- medium: 85,
20
- large: 90,
21
- };
22
- export function validateImageFile(file, config) {
23
- const maxSize = config?.maxFileSize ?? DEFAULT_MAX_SIZE;
24
- const allowed = config?.allowedTypes ?? DEFAULT_ALLOWED_TYPES;
25
- if (file.size > maxSize) {
26
- const mb = Math.round(maxSize / 1024 / 1024);
27
- return { valid: false, error: `File too large (max ${mb}MB)` };
28
- }
29
- if (!allowed.includes(file.type)) {
30
- return { valid: false, error: `Invalid file type. Allowed: JPEG, PNG, WebP` };
31
- }
32
- return { valid: true };
33
- }
34
- export function generateMediaKey(_originalName) {
35
- // All processed output is WebP regardless of the uploaded format.
36
- return `${createId()}.webp`;
37
- }
38
- export function getStorageKey(prefix, entityId, filename, size) {
39
- const sizePrefix = SIZE_PREFIXES[size];
40
- return `${prefix}/${entityId}/${sizePrefix}${filename}`;
41
- }
5
+ import { IMAGE_SIZES, SIZE_QUALITY, generateMediaKey, getStorageKey, validateImageFile, } from './media.js';
42
6
  export async function processAndStore(adapter, file, prefix, entityId, config) {
43
7
  const validation = validateImageFile(file, config);
44
8
  if (!validation.valid)
@@ -65,7 +29,20 @@ export async function processAndStore(adapter, file, prefix, entityId, config) {
65
29
  }
66
30
  // ponytail: existing objects stay JPEG under their old keys — URLs derive
67
31
  // from the stored filename, so old rows keep working without migration.
68
- 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 };
69
46
  }
70
47
  export async function deleteMedia(adapter, prefix, entityId, filename) {
71
48
  const keys = ['original', 'thumbnail', 'medium', 'large'].map((size) => getStorageKey(prefix, entityId, filename, size));
@@ -17,7 +17,7 @@ export interface S3Config {
17
17
  /** Use path-style access (default: true for MinIO/R2, set false for Railway/AWS) */
18
18
  forcePathStyle?: boolean;
19
19
  }
20
- export interface StoredMedia {
20
+ export interface StoredMedia<TDerived = never> {
21
21
  filename: string;
22
22
  originalName: string;
23
23
  /** Storage prefix, e.g. 'tours' or 'avatars' */
@@ -26,6 +26,11 @@ export interface StoredMedia {
26
26
  entityId: string;
27
27
  /** Storage keys for each size */
28
28
  sizes: Record<ImageSize, string>;
29
+ /**
30
+ * Result of `config.derive`, when supplied. Undefined if no hook was given,
31
+ * or if it threw — see `MediaConfig.onDeriveError`.
32
+ */
33
+ derived?: TDerived;
29
34
  }
30
35
  export interface MediaValidationError {
31
36
  isValid: false;
@@ -41,7 +46,35 @@ export interface ValidationResult {
41
46
  valid: boolean;
42
47
  error?: string;
43
48
  }
44
- export interface MediaConfig {
49
+ /** What a `derive` hook receives — the image as uploaded, before any resizing. */
50
+ export interface DeriveSource {
51
+ /** Original bytes, pre-resize, EXIF intact. */
52
+ buffer: Buffer;
53
+ /** The generated storage filename, e.g. `k7x2m9.webp`. */
54
+ filename: string;
55
+ /** The uploaded file's MIME type. */
56
+ mimeType: string;
57
+ }
58
+ /** The validation half of MediaConfig — all `validateImageFile` needs. */
59
+ export interface ValidationConfig {
45
60
  maxFileSize?: number;
46
61
  allowedTypes?: string[];
47
62
  }
63
+ export interface MediaConfig<TDerived = never> extends ValidationConfig {
64
+ /**
65
+ * Optional side-computation over the image, run once during processing while
66
+ * the bytes are already in hand — a placeholder, a blurhash, a dominant
67
+ * colour, EXIF. Whatever it returns is attached to the result as `derived`.
68
+ *
69
+ * Runs after every size has been stored, so a failure cannot orphan a
70
+ * half-written upload. Failures do not propagate: the upload has already
71
+ * succeeded and a derived extra must never invalidate it. `derived` is left
72
+ * undefined and `onDeriveError` is called.
73
+ */
74
+ derive?(source: DeriveSource): Promise<TDerived>;
75
+ /**
76
+ * Called when `derive` throws. The upload still succeeds. Without this the
77
+ * failure is silent, so wire it to your logger.
78
+ */
79
+ onDeriveError?(error: unknown, source: DeriveSource): void;
80
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,4 @@
1
- export type { StorageAdapter, S3Config, StoredMedia, ImageSize, MediaConfig, ValidationResult, } from './core/types.js';
2
- export type { LocalConfig } from './core/local-adapter.js';
3
- export { createS3Adapter } from './core/adapter.js';
4
- export { createLocalAdapter } from './core/local-adapter.js';
5
- export { validateImageFile, generateMediaKey, getStorageKey, processAndStore, deleteMedia, getMediaUrl, IMAGE_SIZES, } from './core/process.js';
1
+ export type { StorageAdapter, S3Config, StoredMedia, ImageSize, MediaConfig, ValidationResult, ValidationConfig, DeriveSource, } from './core/types.js';
2
+ export { validateImageFile, generateMediaKey, getStorageKey, IMAGE_SIZES, DEFAULT_MAX_SIZE, DEFAULT_ALLOWED_TYPES, } from './core/media.js';
6
3
  export { default as ImageUpload } from './components/ImageUpload.svelte';
7
4
  export { default as ImageGallery } from './components/ImageGallery.svelte';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
- export { createS3Adapter } from './core/adapter.js';
2
- export { createLocalAdapter } from './core/local-adapter.js';
3
- export { validateImageFile, generateMediaKey, getStorageKey, processAndStore, deleteMedia, getMediaUrl, IMAGE_SIZES, } from './core/process.js';
1
+ // Client-safe entry. The image pipeline and storage adapters need sharp,
2
+ // node:fs and Buffer, so they live behind '@nomideusz/svelte-media/server'
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
5
  export { default as ImageUpload } from './components/ImageUpload.svelte';
5
6
  export { default as ImageGallery } from './components/ImageGallery.svelte';
@@ -0,0 +1,6 @@
1
+ export type { LocalConfig } from '../core/local-adapter.js';
2
+ export { createS3Adapter } from '../core/adapter.js';
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';
6
+ export type * from '../core/types.js';
@@ -0,0 +1,6 @@
1
+ // Server-only entry: sharp, node:fs and Buffer live behind here.
2
+ export { createS3Adapter } from '../core/adapter.js';
3
+ export { createLocalAdapter } from '../core/local-adapter.js';
4
+ export { processAndStore, deleteMedia, getMediaUrl } from '../core/process.js';
5
+ // Re-exported so server code needs only one import.
6
+ export { validateImageFile, generateMediaKey, getStorageKey, IMAGE_SIZES, } from '../core/media.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomideusz/svelte-media",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Image upload, processing, and S3-compatible storage for Svelte 5 apps.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -12,6 +12,10 @@
12
12
  "types": "./dist/index.d.ts",
13
13
  "svelte": "./dist/index.js",
14
14
  "default": "./dist/index.js"
15
+ },
16
+ "./server": {
17
+ "types": "./dist/server/index.d.ts",
18
+ "default": "./dist/server/index.js"
15
19
  }
16
20
  },
17
21
  "files": [