nuxt-filer 0.0.16 → 0.0.17

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/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',
@@ -192,7 +193,45 @@ filer: {
192
193
  },
193
194
  ```
194
195
 
195
- `@nuxt/image` and `ipx` are declared as optional peer dependencies — they only need to be installed if you want to use this integration.
196
+ `@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.
197
+
198
+ ### External image service (imgproxy / standalone IPX)
199
+
200
+ Image processing can run in a separate service instead of this server, so
201
+ `sharp` and `ipx` aren't needed here and one service can serve many apps:
202
+
203
+ ```ts
204
+ filer: {
205
+ image: {
206
+ service: 'imgproxy', // or 'ipx' for a standalone `npx ipx serve`; default 'local'
207
+ },
208
+ },
209
+ ```
210
+
211
+ ```bash
212
+ NUXT_FILER_IMAGE_BASE_URL=https://img.example.com
213
+ NUXT_FILER_IMAGE_KEY=... # imgproxy signing key + salt (hex); unsigned URLs when unset
214
+ NUXT_FILER_IMAGE_SALT=...
215
+ NUXT_FILER_IMAGE_SOURCE_URL=http://app:3000 # how the service reaches this app
216
+ ```
217
+
218
+ - `<NuxtImg provider="filer">` works unchanged. The image route redirects to a
219
+ signed service URL, so the signing key never reaches the browser.
220
+ - The service fetches originals from `/_filer-ipx/_/<groupId>/<fileId>`, which
221
+ serves the stored bytes. `sourceURL` is the origin it uses for that — e.g. the
222
+ app's address on a private Docker network. Without it, the request's origin
223
+ is used.
224
+ - Upload-time transforms (`upload(..., { transform })`) go through the service
225
+ too: the original is staged under the `_filer-transform` group, the variant
226
+ is fetched, and the staged copy is removed. This requires `sourceURL`.
227
+ `transformImage()` itself still needs `sharp`.
228
+ - imgproxy receives the IPX modifiers translated to its options (`w`, `h`,
229
+ `s`, `fit`, `enlarge`, `q`, `f`, `b`, `pos`, `blur`, `sharpen`, `rotate`);
230
+ modifiers without an equivalent are dropped. A standalone IPX gets them as-is.
231
+ - imgproxy blocks loopback and private source addresses by default. If
232
+ `sourceURL` points at one, set `IMGPROXY_ALLOW_LOOPBACK_SOURCE_ADDRESSES` /
233
+ `IMGPROXY_ALLOW_PRIVATE_SOURCE_ADDRESSES`. A standalone IPX needs the source
234
+ host in `--domains`.
196
235
 
197
236
  ## Resumable uploads (tus)
198
237
 
@@ -323,9 +362,78 @@ export default defineNitroPlugin(() => {
323
362
 
324
363
  > `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
364
 
365
+ ## Drizzle (metadata in your database)
366
+
367
+ `createDrizzleProvider` keeps file metadata in a table of your own
368
+ [Drizzle](https://orm.drizzle.team) database and the bytes in a blob store —
369
+ S3/R2 via `createS3Client()`, or any mounted Nitro storage. It uses only
370
+ Drizzle's core query builder, so it works with every dialect and driver on
371
+ Drizzle 0.36+ and v1.
372
+
373
+ ```ts
374
+ // server/db/schema.ts
375
+ import { pgTable, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core'
376
+
377
+ export const filerFiles = pgTable('filer_files', {
378
+ id: text('id').primaryKey(),
379
+ groupId: text('group_id').notNull(),
380
+ metadata: jsonb('metadata'),
381
+ createdAt: timestamp('created_at'), // optional
382
+ updatedAt: timestamp('updated_at'), // optional
383
+ }, t => [index('filer_files_group_id_idx').on(t.groupId)])
384
+ ```
385
+
386
+ ```ts
387
+ // server/plugins/file-provider.ts (with `filer: { provider: 'custom' }`)
388
+ import { db } from '../utils/db'
389
+ import { filerFiles } from '../db/schema'
390
+
391
+ export default defineNitroPlugin(() => {
392
+ const { s3 } = useRuntimeConfig()
393
+ setFileStorageProvider(createDrizzleProvider({
394
+ db,
395
+ table: filerFiles,
396
+ blobs: createS3Client({
397
+ accessKeyId: s3.accessKeyId,
398
+ secretAccessKey: s3.secretAccessKey,
399
+ endpoint: s3.endpoint,
400
+ bucket: s3.bucket,
401
+ }),
402
+ // or keep bytes on disk in the module's fs storage: blobs: 'documents' (= `filer.storageName`)
403
+ // columns: { id: 'id', groupId: 'groupId', metadata: 'metadata', createdAt: 'createdAt', updatedAt: 'updatedAt' },
404
+ }))
405
+ })
406
+ ```
407
+
408
+ - `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.
409
+ - 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.
410
+ - 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.
411
+ - Requires the optional `drizzle-orm` peer dependency.
412
+
413
+ ### Migrating from the unstorage provider
414
+
415
+ Point `blobs` at the existing mount (`blobs: 'documents'`) so the bytes stay
416
+ where they are, then copy the metadata sidecars into the table once —
417
+ `importUnstorageMetadata` keeps ids and timestamps and skips rows that already
418
+ exist, so re-running it is safe:
419
+
420
+ ```ts
421
+ // server/tasks/filer/import.ts (run with `nuxi task run filer:import`)
422
+ import { db } from '../../utils/db'
423
+ import { filerFiles } from '../../db/schema'
424
+
425
+ export default defineTask({
426
+ meta: { description: 'Copy nuxt-filer metadata into the database' },
427
+ async run() {
428
+ const result = await importUnstorageMetadata({ from: 'documents', db, table: filerFiles })
429
+ return { result } // { imported, skipped }
430
+ },
431
+ })
432
+ ```
433
+
326
434
  ## Custom Provider
327
435
 
328
- For advanced use cases (database-backed metadata, external file sync), implement the `FileStorageProvider` interface and register it in a Nitro plugin:
436
+ For advanced use cases (other databases, external file sync), implement the `FileStorageProvider` interface and register it in a Nitro plugin:
329
437
 
330
438
  ```ts
331
439
  // nuxt.config.ts
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.17",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -43,6 +43,18 @@ const module = defineNuxtModule({
43
43
  {
44
44
  name: "createS3Provider",
45
45
  from: resolver.resolve("./runtime/server/providers/s3")
46
+ },
47
+ {
48
+ name: "createS3Client",
49
+ from: resolver.resolve("./runtime/server/providers/s3")
50
+ },
51
+ {
52
+ name: "createDrizzleProvider",
53
+ from: resolver.resolve("./runtime/server/providers/drizzle")
54
+ },
55
+ {
56
+ name: "importUnstorageMetadata",
57
+ from: resolver.resolve("./runtime/server/providers/drizzle")
46
58
  }
47
59
  ]);
48
60
  const typesSpecifier = "nuxt-filer/runtime/types";
@@ -92,12 +104,29 @@ const module = defineNuxtModule({
92
104
  const imageEnabled = options.image !== false && (imageOpt.enabled ?? true) !== false;
93
105
  const ipxRoute = (imageOpt.route ?? "/_filer-ipx").replace(/\/+$/, "");
94
106
  const providerName = imageOpt.providerName ?? "filer";
95
- const shouldRegisterImage = imageEnabled && (imageOpt.enabled === "force" || hasNuxtModule("@nuxt/image"));
96
- if (shouldRegisterImage) {
107
+ const imageService = imageEnabled ? imageOpt.service ?? "local" : "local";
108
+ if (imageService !== "local") {
109
+ const runtimeFiler = nuxt.options.runtimeConfig.filer ?? {};
110
+ runtimeFiler.image = defu(runtimeFiler.image, {
111
+ baseURL: imageOpt.baseURL ?? "",
112
+ key: imageOpt.key ?? "",
113
+ salt: imageOpt.salt ?? "",
114
+ sourceURL: imageOpt.sourceURL ?? ""
115
+ });
116
+ nuxt.options.runtimeConfig.filer = runtimeFiler;
97
117
  addServerHandler({
98
118
  route: `${ipxRoute}/**`,
99
- handler: resolver.resolve("./runtime/server/handlers/ipx")
119
+ handler: resolver.resolve("./runtime/server/handlers/image-service")
100
120
  });
121
+ }
122
+ const shouldRegisterImage = imageEnabled && (imageOpt.enabled === "force" || hasNuxtModule("@nuxt/image"));
123
+ if (shouldRegisterImage) {
124
+ if (imageService === "local") {
125
+ addServerHandler({
126
+ route: `${ipxRoute}/**`,
127
+ handler: resolver.resolve("./runtime/server/handlers/ipx")
128
+ });
129
+ }
101
130
  const optionsWithImage = nuxt.options;
102
131
  const imageConfig = optionsWithImage.image ?? {};
103
132
  const providers = imageConfig.providers ?? {};
@@ -123,18 +152,21 @@ const module = defineNuxtModule({
123
152
  `export const storageName = ${JSON.stringify(options.storageName)};`,
124
153
  `export const storagePath = ${JSON.stringify(options.storagePath)};`
125
154
  ].join("\n");
126
- nitroConfig.virtual["#nuxt-filer-image"] = `export const ipxRoute = ${JSON.stringify(ipxRoute)}`;
155
+ nitroConfig.virtual["#nuxt-filer-image"] = [
156
+ `export const ipxRoute = ${JSON.stringify(ipxRoute)};`,
157
+ `export const imageService = ${JSON.stringify(imageService)};`
158
+ ].join("\n");
127
159
  nitroConfig.virtual["#nuxt-filer-tus"] = [
128
160
  `export const tusRoute = ${JSON.stringify(tusRoute)};`,
129
161
  `export const tusStagingDir = ${JSON.stringify(tusStagingDir)};`,
130
162
  `export const tusMaxSize = ${JSON.stringify(tusOpt.maxSize ?? 0)};`,
131
163
  `export const tusExpiration = ${JSON.stringify(tusOpt.expiration ?? 0)};`
132
164
  ].join("\n");
165
+ nitroConfig.plugins = nitroConfig.plugins || [];
166
+ nitroConfig.plugins.push(
167
+ resolver.resolve("./runtime/server/plugins/default-storage")
168
+ );
133
169
  if (options.provider === "unstorage") {
134
- nitroConfig.plugins = nitroConfig.plugins || [];
135
- nitroConfig.plugins.push(
136
- resolver.resolve("./runtime/server/plugins/default-storage")
137
- );
138
170
  nitroConfig.plugins.push(
139
171
  resolver.resolve("./runtime/server/plugins/default-provider")
140
172
  );
@@ -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
9
  import { 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",
@@ -18,7 +24,9 @@ const filerStorage = {
18
24
  if (!file) return void 0;
19
25
  const mtime = file.updatedAt ?? file.createdAt ?? /* @__PURE__ */ new Date();
20
26
  return {
21
- mtime,
27
+ // HTTP dates have second precision; without truncating, the
28
+ // `if-modified-since` echo is always "older" than mtime and never 304s.
29
+ mtime: new Date(Math.floor(mtime.getTime() / 1e3) * 1e3),
22
30
  maxAge: 60 * 60 * 24 * 365
23
31
  };
24
32
  },
@@ -31,13 +39,23 @@ const filerStorage = {
31
39
  return data;
32
40
  }
33
41
  };
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));
42
+ function createHandler() {
43
+ const ipx = ipxModule.createIPX({ storage: filerStorage });
44
+ const ipx4 = ipxModule;
45
+ if (ipx4.createIPXFetchHandler && ipx4.parseIPXURL) {
46
+ const { parseIPXURL } = ipx4;
47
+ const fetchHandler = ipx4.createIPXFetchHandler(ipx, {
48
+ parseURL(url) {
49
+ const parsed = new URL(url);
50
+ parsed.pathname = parsed.pathname.slice(ipxRoute.length) || "/";
51
+ return parseIPXURL(parsed.href);
52
+ }
53
+ });
54
+ return defineEventHandler(
55
+ async (event) => sendWebResponse(event, await fetchHandler(toWebRequest(event)))
56
+ );
40
57
  }
41
- return _handler;
58
+ return useBase(ipxRoute, ipxModule.createIPXH3Handler(ipx));
42
59
  }
43
- export default defineEventHandler((event) => getHandler()(event));
60
+ let _handler = null;
61
+ 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
  });
@@ -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
+ }>;