nuxt-filer 0.0.16 → 0.0.19

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.
Files changed (44) hide show
  1. package/README.md +174 -5
  2. package/dist/module.d.mts +19 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +52 -8
  5. package/dist/runtime/server/handlers/image-service.d.ts +12 -0
  6. package/dist/runtime/server/handlers/image-service.js +21 -0
  7. package/dist/runtime/server/handlers/ipx.js +34 -17
  8. package/dist/runtime/server/plugins/default-storage.d.ts +2 -1
  9. package/dist/runtime/server/plugins/default-storage.js +2 -0
  10. package/dist/runtime/server/provider.d.ts +6 -1
  11. package/dist/runtime/server/provider.js +3 -0
  12. package/dist/runtime/server/providers/drizzle.d.ts +75 -0
  13. package/dist/runtime/server/providers/drizzle.js +231 -0
  14. package/dist/runtime/server/providers/s3.d.ts +5 -0
  15. package/dist/runtime/server/providers/s3.js +20 -2
  16. package/dist/runtime/server/providers/unstorage.js +12 -0
  17. package/dist/runtime/server/utils/image-service-runtime.d.ts +9 -0
  18. package/dist/runtime/server/utils/image-service-runtime.js +65 -0
  19. package/dist/runtime/server/utils/image-service.d.ts +36 -0
  20. package/dist/runtime/server/utils/image-service.js +113 -0
  21. package/dist/runtime/server/utils/send.js +6 -5
  22. package/dist/runtime/server/utils/storage.d.ts +1 -0
  23. package/dist/runtime/server/utils/storage.js +9 -2
  24. package/dist/runtime/server/utils/thumbnail.d.ts +19 -0
  25. package/dist/runtime/server/utils/thumbnail.js +54 -0
  26. package/dist/runtime/server/utils/upload.d.ts +57 -0
  27. package/dist/runtime/server/utils/upload.js +81 -0
  28. package/dist/runtime/types.d.ts +5 -0
  29. package/package.json +37 -27
  30. package/src/runtime/server/handlers/image-service.ts +35 -0
  31. package/src/runtime/server/handlers/ipx.ts +58 -23
  32. package/src/runtime/server/plugins/default-storage.ts +6 -1
  33. package/src/runtime/server/provider.ts +13 -1
  34. package/src/runtime/server/providers/drizzle.ts +388 -0
  35. package/src/runtime/server/providers/s3.ts +28 -2
  36. package/src/runtime/server/providers/unstorage.ts +20 -0
  37. package/src/runtime/server/utils/image-service-runtime.ts +87 -0
  38. package/src/runtime/server/utils/image-service.ts +166 -0
  39. package/src/runtime/server/utils/image.ts +2 -2
  40. package/src/runtime/server/utils/send.ts +16 -7
  41. package/src/runtime/server/utils/storage.ts +19 -4
  42. package/src/runtime/server/utils/thumbnail.ts +94 -0
  43. package/src/runtime/server/utils/upload.ts +157 -0
  44. package/src/runtime/types.ts +5 -0
package/README.md CHANGED
@@ -9,7 +9,7 @@ File storage module for Nuxt. Provides a server-side `useFileStorage()` composab
9
9
 
10
10
  ## Features
11
11
 
12
- - **Pluggable provider architecture** — use the built-in unstorage provider or bring your own (Prisma, Drizzle, etc.)
12
+ - **Pluggable provider architecture** — built-in unstorage (default), S3 and Drizzle providers, or bring your own
13
13
  - **File versioning** — built-in version tracking, latest-version filtering, and duplicate detection
14
14
  - **External file sync** — two-way sync with external systems (Jira, SharePoint, etc.) via optional provider interface
15
15
  - **Zero-config default** — works out of the box with local filesystem storage, no database required
@@ -32,7 +32,8 @@ npx nuxi module add nuxt-filer
32
32
  export default defineNuxtConfig({
33
33
  modules: ['nuxt-filer'],
34
34
  filer: {
35
- // Nitro storage mount name (default: 'documents')
35
+ // Nitro storage mount name (default: 'documents'). Mounted on the local fs
36
+ // for every provider unless you configure it yourself under `nitro.storage`.
36
37
  storageName: 'documents',
37
38
  // Base path for fs-lite driver (default: '.data/documents')
38
39
  storagePath: '.data/documents',
@@ -116,6 +117,34 @@ const res = await transformImage(buffer, { width: 64, format: 'webp' })
116
117
 
117
118
  > Image processing requires the optional [`sharp`](https://sharp.pixelplumbing.com/) peer dependency. Install it (`npm i sharp`) only if you use `transform` / `transformImage()` — calling them without `sharp` throws a clear error. Without a `transform`, `upload()` stores the raw bytes unchanged and needs no extra dependency.
118
119
 
120
+ ### Thumbnails and PDF previews with `generateThumbnail()`
121
+
122
+ Builds a preview image from a stored file: images are resized with Sharp, PDFs
123
+ get a page (the first by default) rendered and then resized. It returns
124
+ `null` instead of throwing for unsupported types, unreadable input, or missing
125
+ optional dependencies, so it can run on every upload:
126
+
127
+ ```ts
128
+ const file = await readUploadedFile(event)
129
+ const id = await storage.upload('docs', file.data, { meta: { name: file.name, mime: file.type, type: 'document', version: 1 } })
130
+
131
+ const thumb = await generateThumbnail(file.data, file.type, { width: 300, height: 200 })
132
+ if (thumb) {
133
+ await storage.upload('docs', thumb.data, {
134
+ meta: { name: `thumb_${file.name}`, mime: thumb.mime, type: 'thumbnail', version: 1 },
135
+ })
136
+ }
137
+ ```
138
+
139
+ - Options are those of `transformImage()` plus `page` (PDF page, default `1`).
140
+ Defaults: a 300×300 `inside` box, `webp`, and the first frame only for
141
+ animated images (`animated: true` keeps animation).
142
+ - Requires `sharp`; PDFs additionally need [`unpdf`](https://github.com/unjs/unpdf)
143
+ and `@napi-rs/canvas` (`npm i unpdf @napi-rs/canvas`). All are optional peer
144
+ dependencies; when one is missing, a warning is logged once and `null` returned.
145
+ - On-request transforms (the IPX route, or imgproxy) can't render PDFs, so a
146
+ stored preview like this is the way to show one.
147
+
119
148
  ### `useFileStorage()` API
120
149
 
121
150
  | Method | Description |
@@ -123,8 +152,9 @@ const res = await transformImage(buffer, { width: 64, format: 'webp' })
123
152
  | `upload(groupId, data, options?)` | Store a file, returns its ID. `options.transform` runs the bytes through Sharp first (see above) |
124
153
  | `list(groupId)` | List all files in a group |
125
154
  | `get(groupId, id)` | Get a file with data and metadata |
155
+ | `head(groupId, id)` | Get a file's metadata and timestamps without reading its bytes |
126
156
  | `getData(groupId, id)` | Get raw binary data only |
127
- | `getMeta(id)` | Get metadata only |
157
+ | `getMeta(id)` | Get metadata only. Searches all groups, which is slow on large stores — prefer `head(groupId, id)` |
128
158
  | `updateMeta(id, meta)` | Deep-merge metadata update |
129
159
  | `remove(groupId, id)` | Delete a file |
130
160
  | `clear(groupId)` | Delete all files in a group |
@@ -137,6 +167,35 @@ const res = await transformImage(buffer, { width: 64, format: 'webp' })
137
167
  | `external?.push(groupId, id, data, meta)` | Push to external system |
138
168
  | `external?.pull(groupId, ref)` | Pull from external system |
139
169
 
170
+ ### Validating uploads with `readUploadedFile()`
171
+
172
+ Reads a file from a `multipart/form-data` request and validates it, throwing a
173
+ 400 (no file), 413 (too large) or 415 (type not allowed) with a message naming
174
+ the file and the limit:
175
+
176
+ ```ts
177
+ // server/api/models.post.ts
178
+ export default defineEventHandler(async (event) => {
179
+ const file = await readUploadedFile(event, {
180
+ types: ['image', '.stl', '.3mf', 'application/pdf'],
181
+ maxSize: '50MB',
182
+ })
183
+ const id = await useFileStorage().upload(file.fields.group ?? 'uploads', file.data, {
184
+ meta: { name: file.name, mime: file.type, type: 'model', version: 1 },
185
+ })
186
+ return { id }
187
+ })
188
+ ```
189
+
190
+ - `types` uses the syntax of the HTML `accept` attribute: exact MIME types
191
+ (`'image/png'`), families (`'image'` or `'image/*'`) and extensions
192
+ (`'.stl'`). Extensions help for formats browsers send without a MIME type.
193
+ - `maxSize` takes bytes or a string like `'500KB'`, `'2MB'` (1024-based). An
194
+ oversized request is rejected from its `Content-Length` before the body is read.
195
+ - `field` picks the form field (default `'file'`); the result's `fields` holds
196
+ the other, non-file form fields.
197
+ - `readUploadedFiles(event, { ..., max })` reads several files from the same field.
198
+
140
199
  ### Serving raw files with `sendStoredFile()`
141
200
 
142
201
  The IPX route serves **images** with full HTTP caching. For everything else — original PDFs, non-image downloads, the unprocessed bytes of any file — `sendStoredFile()` streams a stored file back through an H3 event with the same revalidation story.
@@ -192,7 +251,45 @@ filer: {
192
251
  },
193
252
  ```
194
253
 
195
- `@nuxt/image` and `ipx` are declared as optional peer dependencies — they only need to be installed if you want to use this integration.
254
+ `@nuxt/image` and `ipx` are declared as optional peer dependencies — they only need to be installed if you want to use this integration. Both ipx 3 and ipx 4 (pulled in by `@nuxt/image` 2.1+) are supported.
255
+
256
+ ### External image service (imgproxy / standalone IPX)
257
+
258
+ Image processing can run in a separate service instead of this server, so
259
+ `sharp` and `ipx` aren't needed here and one service can serve many apps:
260
+
261
+ ```ts
262
+ filer: {
263
+ image: {
264
+ service: 'imgproxy', // or 'ipx' for a standalone `npx ipx serve`; default 'local'
265
+ },
266
+ },
267
+ ```
268
+
269
+ ```bash
270
+ NUXT_FILER_IMAGE_BASE_URL=https://img.example.com
271
+ NUXT_FILER_IMAGE_KEY=... # imgproxy signing key + salt (hex); unsigned URLs when unset
272
+ NUXT_FILER_IMAGE_SALT=...
273
+ NUXT_FILER_IMAGE_SOURCE_URL=http://app:3000 # how the service reaches this app
274
+ ```
275
+
276
+ - `<NuxtImg provider="filer">` works unchanged. The image route redirects to a
277
+ signed service URL, so the signing key never reaches the browser.
278
+ - The service fetches originals from `/_filer-ipx/_/<groupId>/<fileId>`, which
279
+ serves the stored bytes. `sourceURL` is the origin it uses for that — e.g. the
280
+ app's address on a private Docker network. Without it, the request's origin
281
+ is used.
282
+ - Upload-time transforms (`upload(..., { transform })`) go through the service
283
+ too: the original is staged under the `_filer-transform` group, the variant
284
+ is fetched, and the staged copy is removed. This requires `sourceURL`.
285
+ `transformImage()` itself still needs `sharp`.
286
+ - imgproxy receives the IPX modifiers translated to its options (`w`, `h`,
287
+ `s`, `fit`, `enlarge`, `q`, `f`, `b`, `pos`, `blur`, `sharpen`, `rotate`);
288
+ modifiers without an equivalent are dropped. A standalone IPX gets them as-is.
289
+ - imgproxy blocks loopback and private source addresses by default. If
290
+ `sourceURL` points at one, set `IMGPROXY_ALLOW_LOOPBACK_SOURCE_ADDRESSES` /
291
+ `IMGPROXY_ALLOW_PRIVATE_SOURCE_ADDRESSES`. A standalone IPX needs the source
292
+ host in `--domains`.
196
293
 
197
294
  ## Resumable uploads (tus)
198
295
 
@@ -323,9 +420,78 @@ export default defineNitroPlugin(() => {
323
420
 
324
421
  > `createS3Provider` requires the optional [`aws4fetch`](https://github.com/mhart/aws4fetch) peer dependency (`npm i aws4fetch`). Pass a custom `client` to use a different transport or to unit-test without network.
325
422
 
423
+ ## Drizzle (metadata in your database)
424
+
425
+ `createDrizzleProvider` keeps file metadata in a table of your own
426
+ [Drizzle](https://orm.drizzle.team) database and the bytes in a blob store —
427
+ S3/R2 via `createS3Client()`, or any mounted Nitro storage. It uses only
428
+ Drizzle's core query builder, so it works with every dialect and driver on
429
+ Drizzle 0.36+ and v1.
430
+
431
+ ```ts
432
+ // server/db/schema.ts
433
+ import { pgTable, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core'
434
+
435
+ export const filerFiles = pgTable('filer_files', {
436
+ id: text('id').primaryKey(),
437
+ groupId: text('group_id').notNull(),
438
+ metadata: jsonb('metadata'),
439
+ createdAt: timestamp('created_at'), // optional
440
+ updatedAt: timestamp('updated_at'), // optional
441
+ }, t => [index('filer_files_group_id_idx').on(t.groupId)])
442
+ ```
443
+
444
+ ```ts
445
+ // server/plugins/file-provider.ts (with `filer: { provider: 'custom' }`)
446
+ import { db } from '../utils/db'
447
+ import { filerFiles } from '../db/schema'
448
+
449
+ export default defineNitroPlugin(() => {
450
+ const { s3 } = useRuntimeConfig()
451
+ setFileStorageProvider(createDrizzleProvider({
452
+ db,
453
+ table: filerFiles,
454
+ blobs: createS3Client({
455
+ accessKeyId: s3.accessKeyId,
456
+ secretAccessKey: s3.secretAccessKey,
457
+ endpoint: s3.endpoint,
458
+ bucket: s3.bucket,
459
+ }),
460
+ // or keep bytes on disk in the module's fs storage: blobs: 'documents' (= `filer.storageName`)
461
+ // columns: { id: 'id', groupId: 'groupId', metadata: 'metadata', createdAt: 'createdAt', updatedAt: 'updatedAt' },
462
+ }))
463
+ })
464
+ ```
465
+
466
+ - `columns` maps to the table's schema property names, so an existing table can be used. `createdAt`/`updatedAt` are filled in when the table has them.
467
+ - With a Postgres `jsonb` metadata column, `findByMeta()` (`@>`) and `update()` (`||` merge) run in the database — add a GIN index on `metadata` for large tables. Other column types and dialects filter and merge in JS.
468
+ - Bytes are stored at `<groupId>/data/<id>`, the same layout as the S3 and unstorage providers, so moving metadata into a database keeps existing files readable.
469
+ - Requires the optional `drizzle-orm` peer dependency.
470
+
471
+ ### Migrating from the unstorage provider
472
+
473
+ Point `blobs` at the existing mount (`blobs: 'documents'`) so the bytes stay
474
+ where they are, then copy the metadata sidecars into the table once —
475
+ `importUnstorageMetadata` keeps ids and timestamps and skips rows that already
476
+ exist, so re-running it is safe:
477
+
478
+ ```ts
479
+ // server/tasks/filer/import.ts (run with `nuxi task run filer:import`)
480
+ import { db } from '../../utils/db'
481
+ import { filerFiles } from '../../db/schema'
482
+
483
+ export default defineTask({
484
+ meta: { description: 'Copy nuxt-filer metadata into the database' },
485
+ async run() {
486
+ const result = await importUnstorageMetadata({ from: 'documents', db, table: filerFiles })
487
+ return { result } // { imported, skipped }
488
+ },
489
+ })
490
+ ```
491
+
326
492
  ## Custom Provider
327
493
 
328
- For advanced use cases (database-backed metadata, external file sync), implement the `FileStorageProvider` interface and register it in a Nitro plugin:
494
+ For advanced use cases (other databases, external file sync), implement the `FileStorageProvider` interface and register it in a Nitro plugin:
329
495
 
330
496
  ```ts
331
497
  // nuxt.config.ts
@@ -372,6 +538,9 @@ export default defineNitroPlugin(() => {
372
538
  interface FileStorageProvider {
373
539
  create(groupId: string, data: Buffer | Uint8Array, meta?: FileMeta): Promise<{ id: string }>
374
540
  get(groupId: string, id: string): Promise<StoredFile | null>
541
+ // Optional: metadata without the bytes. Used by sendStoredFile and the IPX
542
+ // route to answer 304s without reading the file; falls back to get().
543
+ head?(groupId: string, id: string): Promise<StoredFile | null>
375
544
  getData(groupId: string, id: string): Promise<Buffer | null>
376
545
  getMeta(id: string): Promise<FileMeta | null>
377
546
  list(groupId: string): Promise<StoredFile[]>
package/dist/module.d.mts CHANGED
@@ -12,6 +12,25 @@ interface FilerImageOptions {
12
12
  route?: string;
13
13
  /** Name to register the @nuxt/image provider under. Default: `filer`. */
14
14
  providerName?: string;
15
+ /**
16
+ * Where images are processed. `'local'` (default) runs IPX/sharp in this
17
+ * server. `'imgproxy'` or `'ipx'` (a standalone `ipx serve`) offload it: the
18
+ * image route redirects to the service, and upload-time transforms are
19
+ * fetched from it — so `sharp`/`ipx` aren't needed here.
20
+ */
21
+ service?: 'local' | 'imgproxy' | 'ipx';
22
+ /** Base URL of the image service. Runtime override: `NUXT_FILER_IMAGE_BASE_URL`. */
23
+ baseURL?: string;
24
+ /** imgproxy signing key (hex). Prefer `NUXT_FILER_IMAGE_KEY`. Unsigned URLs when unset. */
25
+ key?: string;
26
+ /** imgproxy signing salt (hex). Prefer `NUXT_FILER_IMAGE_SALT`. */
27
+ salt?: string;
28
+ /**
29
+ * Origin the service fetches originals from (e.g. `http://app:3000` on a
30
+ * private network). Defaults to the request origin; required for
31
+ * upload-time transforms. Runtime override: `NUXT_FILER_IMAGE_SOURCE_URL`.
32
+ */
33
+ sourceURL?: string;
15
34
  }
16
35
  interface FilerTusOptions {
17
36
  /** Enable the tus endpoint + composable. Defaults to `true` when the `tus` option is an object. */
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-filer",
3
3
  "configKey": "filer",
4
- "version": "0.0.16",
4
+ "version": "0.0.19",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -21,6 +21,18 @@ const module = defineNuxtModule({
21
21
  name: "useFileStorage",
22
22
  from: resolver.resolve("./runtime/server/utils/storage")
23
23
  },
24
+ {
25
+ name: "readUploadedFile",
26
+ from: resolver.resolve("./runtime/server/utils/upload")
27
+ },
28
+ {
29
+ name: "readUploadedFiles",
30
+ from: resolver.resolve("./runtime/server/utils/upload")
31
+ },
32
+ {
33
+ name: "generateThumbnail",
34
+ from: resolver.resolve("./runtime/server/utils/thumbnail")
35
+ },
24
36
  {
25
37
  name: "transformImage",
26
38
  from: resolver.resolve("./runtime/server/utils/image")
@@ -43,6 +55,18 @@ const module = defineNuxtModule({
43
55
  {
44
56
  name: "createS3Provider",
45
57
  from: resolver.resolve("./runtime/server/providers/s3")
58
+ },
59
+ {
60
+ name: "createS3Client",
61
+ from: resolver.resolve("./runtime/server/providers/s3")
62
+ },
63
+ {
64
+ name: "createDrizzleProvider",
65
+ from: resolver.resolve("./runtime/server/providers/drizzle")
66
+ },
67
+ {
68
+ name: "importUnstorageMetadata",
69
+ from: resolver.resolve("./runtime/server/providers/drizzle")
46
70
  }
47
71
  ]);
48
72
  const typesSpecifier = "nuxt-filer/runtime/types";
@@ -92,12 +116,29 @@ const module = defineNuxtModule({
92
116
  const imageEnabled = options.image !== false && (imageOpt.enabled ?? true) !== false;
93
117
  const ipxRoute = (imageOpt.route ?? "/_filer-ipx").replace(/\/+$/, "");
94
118
  const providerName = imageOpt.providerName ?? "filer";
95
- const shouldRegisterImage = imageEnabled && (imageOpt.enabled === "force" || hasNuxtModule("@nuxt/image"));
96
- if (shouldRegisterImage) {
119
+ const imageService = imageEnabled ? imageOpt.service ?? "local" : "local";
120
+ if (imageService !== "local") {
121
+ const runtimeFiler = nuxt.options.runtimeConfig.filer ?? {};
122
+ runtimeFiler.image = defu(runtimeFiler.image, {
123
+ baseURL: imageOpt.baseURL ?? "",
124
+ key: imageOpt.key ?? "",
125
+ salt: imageOpt.salt ?? "",
126
+ sourceURL: imageOpt.sourceURL ?? ""
127
+ });
128
+ nuxt.options.runtimeConfig.filer = runtimeFiler;
97
129
  addServerHandler({
98
130
  route: `${ipxRoute}/**`,
99
- handler: resolver.resolve("./runtime/server/handlers/ipx")
131
+ handler: resolver.resolve("./runtime/server/handlers/image-service")
100
132
  });
133
+ }
134
+ const shouldRegisterImage = imageEnabled && (imageOpt.enabled === "force" || hasNuxtModule("@nuxt/image"));
135
+ if (shouldRegisterImage) {
136
+ if (imageService === "local") {
137
+ addServerHandler({
138
+ route: `${ipxRoute}/**`,
139
+ handler: resolver.resolve("./runtime/server/handlers/ipx")
140
+ });
141
+ }
101
142
  const optionsWithImage = nuxt.options;
102
143
  const imageConfig = optionsWithImage.image ?? {};
103
144
  const providers = imageConfig.providers ?? {};
@@ -123,18 +164,21 @@ const module = defineNuxtModule({
123
164
  `export const storageName = ${JSON.stringify(options.storageName)};`,
124
165
  `export const storagePath = ${JSON.stringify(options.storagePath)};`
125
166
  ].join("\n");
126
- nitroConfig.virtual["#nuxt-filer-image"] = `export const ipxRoute = ${JSON.stringify(ipxRoute)}`;
167
+ nitroConfig.virtual["#nuxt-filer-image"] = [
168
+ `export const ipxRoute = ${JSON.stringify(ipxRoute)};`,
169
+ `export const imageService = ${JSON.stringify(imageService)};`
170
+ ].join("\n");
127
171
  nitroConfig.virtual["#nuxt-filer-tus"] = [
128
172
  `export const tusRoute = ${JSON.stringify(tusRoute)};`,
129
173
  `export const tusStagingDir = ${JSON.stringify(tusStagingDir)};`,
130
174
  `export const tusMaxSize = ${JSON.stringify(tusOpt.maxSize ?? 0)};`,
131
175
  `export const tusExpiration = ${JSON.stringify(tusOpt.expiration ?? 0)};`
132
176
  ].join("\n");
177
+ nitroConfig.plugins = nitroConfig.plugins || [];
178
+ nitroConfig.plugins.push(
179
+ resolver.resolve("./runtime/server/plugins/default-storage")
180
+ );
133
181
  if (options.provider === "unstorage") {
134
- nitroConfig.plugins = nitroConfig.plugins || [];
135
- nitroConfig.plugins.push(
136
- resolver.resolve("./runtime/server/plugins/default-storage")
137
- );
138
182
  nitroConfig.plugins.push(
139
183
  resolver.resolve("./runtime/server/plugins/default-provider")
140
184
  );
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Image route for an external service (imgproxy / standalone IPX). Same URL
3
+ * shape as the local IPX route — `<route>/<modifiers>/<groupId>/<id>` — so the
4
+ * `@nuxt/image` provider is unchanged:
5
+ *
6
+ * - `_` (no modifiers) serves the stored original. This is what the service
7
+ * fetches as its source.
8
+ * - anything else redirects to the service, so signing keys stay server-side
9
+ * and this server never loads sharp/IPX.
10
+ */
11
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void | Buffer<ArrayBufferLike> | null>>;
12
+ export default _default;
@@ -0,0 +1,21 @@
1
+ import { defineEventHandler, createError, getRequestURL, sendRedirect, setResponseHeader } from "h3";
2
+ import { ipxRoute } from "#nuxt-filer-image";
3
+ import { sendStoredFile } from "../utils/send.js";
4
+ import { imageServiceURL, parseModifiers, sourceURL } from "../utils/image-service.js";
5
+ import { useImageService } from "../utils/image-service-runtime.js";
6
+ export default defineEventHandler(async (event) => {
7
+ const path = event.path.slice(ipxRoute.length).split("?")[0].replace(/^\/+/, "");
8
+ const [modifiers = "", ...segments] = path.split("/");
9
+ const parts = segments.filter(Boolean).map(decodeURIComponent);
10
+ if (parts.length < 2) {
11
+ throw createError({ statusCode: 404, statusMessage: "Image not found" });
12
+ }
13
+ const id = parts.pop();
14
+ const groupId = parts.join("/");
15
+ if (modifiers === "_") return sendStoredFile(event, groupId, id);
16
+ const config = useImageService();
17
+ const origin = config.sourceURL ?? getRequestURL(event).origin;
18
+ const url = await imageServiceURL(config, parseModifiers(modifiers), sourceURL(config, origin, groupId, id));
19
+ setResponseHeader(event, "cache-control", "public, max-age=86400");
20
+ return sendRedirect(event, url, 302);
21
+ });
@@ -1,11 +1,17 @@
1
- import { defineEventHandler, useBase } from "h3";
2
- import { createIPX, createIPXH3Handler } from "ipx";
1
+ import {
2
+ defineEventHandler,
3
+ sendWebResponse,
4
+ toWebRequest,
5
+ useBase
6
+ } from "h3";
7
+ import * as ipxModule from "ipx";
3
8
  import { ipxRoute } from "#nuxt-filer-image";
4
- import { useFileStorageProvider } from "../provider.js";
9
+ import { headStoredFile, useFileStorageProvider } from "../provider.js";
5
10
  function parseId(id) {
6
- const lastSlash = id.lastIndexOf("/");
7
- if (lastSlash <= 0 || lastSlash === id.length - 1) return null;
8
- return [id.slice(0, lastSlash), id.slice(lastSlash + 1)];
11
+ const trimmed = id.replace(/^\/+/, "");
12
+ const lastSlash = trimmed.lastIndexOf("/");
13
+ if (lastSlash <= 0 || lastSlash === trimmed.length - 1) return null;
14
+ return [trimmed.slice(0, lastSlash), trimmed.slice(lastSlash + 1)];
9
15
  }
10
16
  const filerStorage = {
11
17
  name: "nuxt-filer",
@@ -13,12 +19,13 @@ const filerStorage = {
13
19
  const parsed = parseId(id);
14
20
  if (!parsed) return void 0;
15
21
  const [groupId, fileId] = parsed;
16
- const provider = useFileStorageProvider();
17
- const file = await provider.get(groupId, fileId);
22
+ const file = await headStoredFile(useFileStorageProvider(), groupId, fileId);
18
23
  if (!file) return void 0;
19
24
  const mtime = file.updatedAt ?? file.createdAt ?? /* @__PURE__ */ new Date();
20
25
  return {
21
- mtime,
26
+ // HTTP dates have second precision; without truncating, the
27
+ // `if-modified-since` echo is always "older" than mtime and never 304s.
28
+ mtime: new Date(Math.floor(mtime.getTime() / 1e3) * 1e3),
22
29
  maxAge: 60 * 60 * 24 * 365
23
30
  };
24
31
  },
@@ -31,13 +38,23 @@ const filerStorage = {
31
38
  return data;
32
39
  }
33
40
  };
34
- let _handler = null;
35
- let _ipx = null;
36
- function getHandler() {
37
- if (!_handler) {
38
- _ipx = createIPX({ storage: filerStorage });
39
- _handler = useBase(ipxRoute, createIPXH3Handler(_ipx));
41
+ function createHandler() {
42
+ const ipx = ipxModule.createIPX({ storage: filerStorage });
43
+ const ipx4 = ipxModule;
44
+ if (ipx4.createIPXFetchHandler && ipx4.parseIPXURL) {
45
+ const { parseIPXURL } = ipx4;
46
+ const fetchHandler = ipx4.createIPXFetchHandler(ipx, {
47
+ parseURL(url) {
48
+ const parsed = new URL(url);
49
+ parsed.pathname = parsed.pathname.slice(ipxRoute.length) || "/";
50
+ return parseIPXURL(parsed.href);
51
+ }
52
+ });
53
+ return defineEventHandler(
54
+ async (event) => sendWebResponse(event, await fetchHandler(toWebRequest(event)))
55
+ );
40
56
  }
41
- return _handler;
57
+ return useBase(ipxRoute, ipxModule.createIPXH3Handler(ipx));
42
58
  }
43
- export default defineEventHandler((event) => getHandler()(event));
59
+ let _handler = null;
60
+ export default defineEventHandler((event) => (_handler ??= createHandler())(event));
@@ -2,7 +2,8 @@
2
2
  * Mount the default filesystem-backed storage for nuxt-filer. We mount
3
3
  * via a Nitro plugin rather than `nitroConfig.storage` so that our
4
4
  * custom fs driver is bundled with the plugin and there is no runtime
5
- * module resolution against the package's `dist/`.
5
+ * module resolution against the package's `dist/`. A mount the app already
6
+ * configured under the same name (`nitro.storage`) is left alone.
6
7
  */
7
8
  declare const _default: import("nitropack/types").NitroAppPlugin;
8
9
  export default _default;
@@ -3,5 +3,7 @@ import { storageName, storagePath } from "#nuxt-filer-options";
3
3
  import fsDriver from "../drivers/fs.js";
4
4
  export default defineNitroPlugin(() => {
5
5
  const storage = useStorage();
6
+ const mounted = storage.getMount(storageName).base.replace(/:$/, "");
7
+ if (mounted === storageName.replace(/[:/]+/g, ":").replace(/:$/, "")) return;
6
8
  storage.mount(storageName, fsDriver({ base: storagePath }));
7
9
  });
@@ -1,3 +1,8 @@
1
- import type { FileStorageProvider } from '../../runtime/types.js';
1
+ import type { FileStorageProvider, StoredFile } from '../../runtime/types.js';
2
2
  export declare function setFileStorageProvider(provider: FileStorageProvider): void;
3
3
  export declare function useFileStorageProvider(): FileStorageProvider;
4
+ /**
5
+ * A file's metadata without its bytes when the provider supports `head()`;
6
+ * otherwise `get()`, whose `data` callers can reuse instead of reading again.
7
+ */
8
+ export declare function headStoredFile(provider: FileStorageProvider, groupId: string, id: string): Promise<StoredFile | null>;
@@ -10,3 +10,6 @@ export function useFileStorageProvider() {
10
10
  }
11
11
  return _provider;
12
12
  }
13
+ export async function headStoredFile(provider, groupId, id) {
14
+ return provider.head ? provider.head(groupId, id) : provider.get(groupId, id);
15
+ }
@@ -0,0 +1,75 @@
1
+ import type { Table } from 'drizzle-orm';
2
+ import type { FileStorageProvider } from '../../../runtime/types.js';
3
+ /**
4
+ * Where the drizzle provider keeps file bytes — the database only holds
5
+ * metadata. The {@link S3Client} from `createS3Client()` satisfies this, and a
6
+ * string is shorthand for a Nitro storage mount name.
7
+ */
8
+ export interface BlobStore {
9
+ put(key: string, body: Buffer | Uint8Array, contentType?: string): Promise<void>;
10
+ get(key: string): Promise<Buffer | null>;
11
+ delete(key: string): Promise<void>;
12
+ }
13
+ /**
14
+ * The slice of a Drizzle database the provider calls. Kept loose on purpose:
15
+ * Drizzle's per-dialect builders (pg/mysql/sqlite, v0.x and v1) don't share a
16
+ * common base type, so any `drizzle(...)` instance is accepted here.
17
+ */
18
+ export interface DrizzleDatabase {
19
+ select: (...args: any[]) => any;
20
+ insert: (table: any) => any;
21
+ update: (table: any) => any;
22
+ delete: (table: any) => any;
23
+ }
24
+ export interface DrizzleProviderOptions {
25
+ /** Your Drizzle database instance (any dialect, any async or sync driver). */
26
+ db: DrizzleDatabase;
27
+ /** The table holding file metadata. */
28
+ table: Table;
29
+ /**
30
+ * Where file bytes are stored: a {@link BlobStore} (e.g. `createS3Client()`)
31
+ * or the name of a mounted Nitro storage.
32
+ */
33
+ blobs: BlobStore | string;
34
+ /** Schema property names of the columns used. Unset timestamps are optional. */
35
+ columns?: {
36
+ /** Default: `'id'`. */
37
+ id?: string;
38
+ /** Default: `'groupId'`. */
39
+ groupId?: string;
40
+ /** JSON column. Default: `'metadata'`. */
41
+ metadata?: string;
42
+ /** Set on insert when the table has it. Default: `'createdAt'`. */
43
+ createdAt?: string;
44
+ /** Set on insert/update when the table has it. Default: `'updatedAt'`. */
45
+ updatedAt?: string;
46
+ };
47
+ }
48
+ /**
49
+ * Drizzle-backed {@link FileStorageProvider}: metadata lives in a database
50
+ * table, bytes in a {@link BlobStore}. Uses only Drizzle's core query builder,
51
+ * which is unchanged between v0.x and v1.
52
+ *
53
+ * When the metadata column is Postgres `jsonb`, `findByMeta` and `update` run
54
+ * in the database (`@>` containment / `||` merge); other dialects filter and
55
+ * merge in JS.
56
+ */
57
+ export declare function createDrizzleProvider(options: DrizzleProviderOptions): FileStorageProvider;
58
+ export interface ImportUnstorageMetadataOptions {
59
+ /** Nitro storage mount the unstorage provider wrote to (e.g. `'documents'`). */
60
+ from: string;
61
+ db: DrizzleDatabase;
62
+ table: Table;
63
+ columns?: DrizzleProviderOptions['columns'];
64
+ }
65
+ /**
66
+ * One-off migration from the unstorage provider: copies its JSON metadata
67
+ * sidecars (`<groupId>:meta:<id>`) into the Drizzle table, keeping ids and
68
+ * timestamps. Bytes stay where they are — point `createDrizzleProvider`'s
69
+ * `blobs` at the same mount. Rows that already exist are skipped, so it is
70
+ * safe to re-run.
71
+ */
72
+ export declare function importUnstorageMetadata(options: ImportUnstorageMetadataOptions): Promise<{
73
+ imported: number;
74
+ skipped: number;
75
+ }>;