nuxt-filer 0.0.15 → 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.
Files changed (40) hide show
  1. package/README.md +209 -4
  2. package/dist/module.d.mts +37 -1
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +82 -13
  5. package/dist/runtime/composables/tus.d.ts +18 -0
  6. package/dist/runtime/composables/tus.js +137 -0
  7. package/dist/runtime/server/handlers/image-service.d.ts +12 -0
  8. package/dist/runtime/server/handlers/image-service.js +21 -0
  9. package/dist/runtime/server/handlers/ipx.js +32 -14
  10. package/dist/runtime/server/handlers/tus.d.ts +2 -0
  11. package/dist/runtime/server/handlers/tus.js +36 -0
  12. package/dist/runtime/server/plugins/default-storage.d.ts +2 -1
  13. package/dist/runtime/server/plugins/default-storage.js +2 -0
  14. package/dist/runtime/server/providers/drizzle.d.ts +75 -0
  15. package/dist/runtime/server/providers/drizzle.js +225 -0
  16. package/dist/runtime/server/providers/s3.d.ts +5 -0
  17. package/dist/runtime/server/providers/s3.js +15 -3
  18. package/dist/runtime/server/utils/image-service-runtime.d.ts +9 -0
  19. package/dist/runtime/server/utils/image-service-runtime.js +65 -0
  20. package/dist/runtime/server/utils/image-service.d.ts +36 -0
  21. package/dist/runtime/server/utils/image-service.js +113 -0
  22. package/dist/runtime/server/utils/storage.js +3 -1
  23. package/dist/runtime/server/utils/tus.d.ts +33 -0
  24. package/dist/runtime/server/utils/tus.js +132 -0
  25. package/dist/runtime/types.d.ts +51 -0
  26. package/dist/types.d.mts +1 -1
  27. package/package.json +19 -8
  28. package/src/runtime/composables/tus.ts +173 -0
  29. package/src/runtime/server/handlers/image-service.ts +35 -0
  30. package/src/runtime/server/handlers/ipx.ts +55 -20
  31. package/src/runtime/server/handlers/tus.ts +60 -0
  32. package/src/runtime/server/plugins/default-storage.ts +6 -1
  33. package/src/runtime/server/providers/drizzle.ts +379 -0
  34. package/src/runtime/server/providers/s3.ts +24 -5
  35. package/src/runtime/server/utils/image-service-runtime.ts +87 -0
  36. package/src/runtime/server/utils/image-service.ts +166 -0
  37. package/src/runtime/server/utils/image.ts +2 -2
  38. package/src/runtime/server/utils/storage.ts +8 -3
  39. package/src/runtime/server/utils/tus.ts +192 -0
  40. package/src/runtime/types.ts +54 -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
@@ -17,6 +17,7 @@ File storage module for Nuxt. Provides a server-side `useFileStorage()` composab
17
17
  - **Group-based organization** — files are organized by `groupId` (project, ticket, order, etc.)
18
18
  - **`@nuxt/image` integration** — when `@nuxt/image` is installed, an IPX endpoint is wired up automatically so `<NuxtImg provider="filer" src="<groupId>/<id>" />` returns optimized variants of stored files
19
19
  - **Upload-time image processing** — optionally run images through Sharp when storing them (resize, format-convert, optimize, preserve animation) via a per-call `transform` option or the standalone `transformImage()` util
20
+ - **Resumable uploads (tus)** — opt-in [tus](https://tus.io) endpoint backed by `@tus/server`, a client-side `useTusUpload()` composable, and `useTusStaging().promote()` to move finished uploads into the file storage
20
21
 
21
22
  ## Quick Setup
22
23
 
@@ -31,7 +32,8 @@ npx nuxi module add nuxt-filer
31
32
  export default defineNuxtConfig({
32
33
  modules: ['nuxt-filer'],
33
34
  filer: {
34
- // 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`.
35
37
  storageName: 'documents',
36
38
  // Base path for fs-lite driver (default: '.data/documents')
37
39
  storagePath: '.data/documents',
@@ -191,7 +193,141 @@ filer: {
191
193
  },
192
194
  ```
193
195
 
194
- `@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`.
235
+
236
+ ## Resumable uploads (tus)
237
+
238
+ Large or flaky-network uploads can use the [tus protocol](https://tus.io)
239
+ instead of a single multipart POST. Uploads are staged chunk-by-chunk into a
240
+ local directory (survives connection drops and page reloads), then *promoted*
241
+ into the regular file storage by one of your own server routes — which is
242
+ where you enforce auth and attach domain metadata.
243
+
244
+ ```ts
245
+ // nuxt.config.ts
246
+ export default defineNuxtConfig({
247
+ modules: ['nuxt-filer'],
248
+ filer: {
249
+ tus: {
250
+ enabled: true,
251
+ route: '/_filer-tus', // default
252
+ stagingDir: '.data/tus', // default
253
+ maxSize: 500 * 1024 * 1024, // optional, bytes
254
+ expiration: 24 * 60 * 60 * 1000, // optional: purge stale staged uploads
255
+ },
256
+ },
257
+ })
258
+ ```
259
+
260
+ Client side, the auto-imported `useTusUpload()` composable wraps
261
+ [`tus-js-client`](https://github.com/tus/tus-js-client) with reactive state:
262
+
263
+ ```vue
264
+ <script setup lang="ts">
265
+ const tus = useTusUpload({
266
+ metadata: (file) => ({ comment: 'from the web app' }), // extra tus metadata
267
+ // cleanupOnPageHide: true, // sendBeacon-delete staged uploads on close
268
+ })
269
+
270
+ function onSelect(e: Event) {
271
+ tus.add(Array.from((e.target as HTMLInputElement).files ?? []))
272
+ }
273
+
274
+ async function save() {
275
+ for (const item of tus.completed.value) {
276
+ await $fetch('/api/documents/finalize', {
277
+ method: 'POST',
278
+ body: { tusId: item.tusId, name: item.file.name },
279
+ })
280
+ }
281
+ tus.clear()
282
+ }
283
+ </script>
284
+ ```
285
+
286
+ Each entry in `tus.items` tracks `progress`, `complete`, `tusId`, and `error`;
287
+ `tus.remove(name)` aborts and deletes a staged upload, `tus.cancel()` discards
288
+ everything. Interrupted uploads resume automatically (retry backoff, restart
289
+ on `online`, and — via the tus fingerprint — across page reloads).
290
+
291
+ Server side, promote a finished upload into the file storage:
292
+
293
+ ```ts
294
+ // server/api/documents/finalize.post.ts
295
+ export default defineEventHandler(async (event) => {
296
+ const body = await readBody(event)
297
+
298
+ const { id, meta } = await useTusStaging().promote(body.tusId, 'my-group', {
299
+ meta: { type: 'document' }, // merged over tus filename/filetype
300
+ // transform: { width: 1600 }, // optional sharp processing
301
+ })
302
+
303
+ return { id, meta }
304
+ })
305
+ ```
306
+
307
+ `useTusStaging()` also exposes `info()`, `read()`, and `remove()` for staged
308
+ uploads. To protect or customize the endpoint itself (auth, upload hooks, a
309
+ different datastore), configure the underlying `@tus/server` from a Nitro
310
+ plugin — the server is created lazily on the first request:
311
+
312
+ ```ts
313
+ // server/plugins/tus.ts
314
+ export default defineNitroPlugin(() => {
315
+ setTusServerOptions({
316
+ async onIncomingRequest(req) {
317
+ // throw { status_code: 401, body: 'Unauthorized' } to reject
318
+ },
319
+ })
320
+ })
321
+ ```
322
+
323
+ Notes:
324
+
325
+ - The endpoint handles the full tus lifecycle (create/HEAD/PATCH/DELETE); a
326
+ `POST <route>/cleanup` sub-route accepts `{ tusIds: string[] }` from
327
+ `navigator.sendBeacon` for page-close cleanup (used by `cleanupOnPageHide`).
328
+ - Staging uses `@tus/file-store` on the local filesystem, independent of the
329
+ configured storage provider — promotion works with any provider, including
330
+ S3 and custom ones.
195
331
 
196
332
  ## S3 storage
197
333
 
@@ -226,9 +362,78 @@ export default defineNitroPlugin(() => {
226
362
 
227
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.
228
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
+
229
434
  ## Custom Provider
230
435
 
231
- 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:
232
437
 
233
438
  ```ts
234
439
  // nuxt.config.ts
package/dist/module.d.mts CHANGED
@@ -12,6 +12,40 @@ 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;
34
+ }
35
+ interface FilerTusOptions {
36
+ /** Enable the tus endpoint + composable. Defaults to `true` when the `tus` option is an object. */
37
+ enabled?: boolean;
38
+ /** Base route of the tus endpoint. Default: `/_filer-tus`. */
39
+ route?: string;
40
+ /** Directory where in-progress uploads are staged. Default: `.data/tus`. */
41
+ stagingDir?: string;
42
+ /** Max upload size in bytes. Default: unlimited. */
43
+ maxSize?: number;
44
+ /**
45
+ * Staged uploads older than this (ms) are expired: reported to clients via
46
+ * `Upload-Expires` and purged periodically. Default: never expire.
47
+ */
48
+ expiration?: number;
15
49
  }
16
50
  interface ModuleOptions {
17
51
  /** Nitro storage mount name for binary file data. Default: 'documents' */
@@ -22,8 +56,10 @@ interface ModuleOptions {
22
56
  provider?: 'unstorage' | 'custom';
23
57
  /** @nuxt/image integration. Set to `false` to disable, or pass an object to override defaults. */
24
58
  image?: boolean | FilerImageOptions;
59
+ /** Resumable uploads via the tus protocol. Opt-in: pass `true` or an options object. */
60
+ tus?: boolean | FilerTusOptions;
25
61
  }
26
62
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
27
63
 
28
64
  export { _default as default };
29
- export type { FilerImageOptions, ModuleOptions };
65
+ export type { FilerImageOptions, FilerTusOptions, ModuleOptions };
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-filer",
3
3
  "configKey": "filer",
4
- "version": "0.0.15",
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
@@ -1,5 +1,6 @@
1
- import { defineNuxtModule, createResolver, addServerImports, addImports, hasNuxtModule, addServerHandler, installModule } from '@nuxt/kit';
1
+ import { defineNuxtModule, createResolver, addServerImports, addImports, addServerHandler, hasNuxtModule, installModule } from '@nuxt/kit';
2
2
  import { consola } from 'consola';
3
+ import { defu } from 'defu';
3
4
 
4
5
  const module = defineNuxtModule({
5
6
  meta: {
@@ -10,7 +11,8 @@ const module = defineNuxtModule({
10
11
  storageName: "documents",
11
12
  storagePath: ".data/documents",
12
13
  provider: "unstorage",
13
- image: true
14
+ image: true,
15
+ tus: false
14
16
  },
15
17
  async setup(options, nuxt) {
16
18
  const resolver = createResolver(import.meta.url);
@@ -41,6 +43,18 @@ const module = defineNuxtModule({
41
43
  {
42
44
  name: "createS3Provider",
43
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")
44
58
  }
45
59
  ]);
46
60
  const typesSpecifier = "nuxt-filer/runtime/types";
@@ -56,19 +70,65 @@ const module = defineNuxtModule({
56
70
  },
57
71
  { name: "ImageFormat", from: typesSpecifier, type: true },
58
72
  { name: "ImageTransformOptions", from: typesSpecifier, type: true },
59
- { name: "ImageTransformResult", from: typesSpecifier, type: true }
73
+ { name: "ImageTransformResult", from: typesSpecifier, type: true },
74
+ { name: "TusUploadState", from: typesSpecifier, type: true },
75
+ { name: "UseTusUploadOptions", from: typesSpecifier, type: true },
76
+ { name: "TusPromoteOptions", from: typesSpecifier, type: true }
60
77
  ]);
78
+ const tusOpt = typeof options.tus === "object" && options.tus !== null ? options.tus : {};
79
+ const tusEnabled = options.tus === true || typeof options.tus === "object" && options.tus !== null && tusOpt.enabled !== false;
80
+ const tusRoute = (tusOpt.route ?? "/_filer-tus").replace(/\/+$/, "");
81
+ const tusStagingDir = tusOpt.stagingDir ?? ".data/tus";
82
+ if (tusEnabled) {
83
+ const tusHandler = resolver.resolve("./runtime/server/handlers/tus");
84
+ addServerHandler({ route: tusRoute, handler: tusHandler });
85
+ addServerHandler({ route: `${tusRoute}/**`, handler: tusHandler });
86
+ const tusUtilsSpecifier = resolver.resolve("./runtime/server/utils/tus");
87
+ addServerImports([
88
+ { name: "useTusServer", from: tusUtilsSpecifier },
89
+ { name: "setTusServerOptions", from: tusUtilsSpecifier },
90
+ { name: "useTusStaging", from: tusUtilsSpecifier }
91
+ ]);
92
+ addImports([
93
+ {
94
+ name: "useTusUpload",
95
+ from: resolver.resolve("./runtime/composables/tus")
96
+ }
97
+ ]);
98
+ nuxt.options.runtimeConfig.public.filer = defu(
99
+ nuxt.options.runtimeConfig.public.filer,
100
+ { tusRoute }
101
+ );
102
+ }
61
103
  const imageOpt = typeof options.image === "object" && options.image !== null ? options.image : {};
62
104
  const imageEnabled = options.image !== false && (imageOpt.enabled ?? true) !== false;
63
105
  const ipxRoute = (imageOpt.route ?? "/_filer-ipx").replace(/\/+$/, "");
64
106
  const providerName = imageOpt.providerName ?? "filer";
65
- const shouldRegisterImage = imageEnabled && (imageOpt.enabled === "force" || hasNuxtModule("@nuxt/image"));
66
- 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;
67
117
  addServerHandler({
68
118
  route: `${ipxRoute}/**`,
69
- handler: resolver.resolve("./runtime/server/handlers/ipx")
119
+ handler: resolver.resolve("./runtime/server/handlers/image-service")
70
120
  });
71
- const imageConfig = nuxt.options.image ?? {};
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
+ }
130
+ const optionsWithImage = nuxt.options;
131
+ const imageConfig = optionsWithImage.image ?? {};
72
132
  const providers = imageConfig.providers ?? {};
73
133
  providers[providerName] = {
74
134
  name: providerName,
@@ -76,7 +136,7 @@ const module = defineNuxtModule({
76
136
  options: { baseURL: ipxRoute }
77
137
  };
78
138
  imageConfig.providers = providers;
79
- nuxt.options.image = imageConfig;
139
+ optionsWithImage.image = imageConfig;
80
140
  const imageAlreadyLoaded = nuxt.options._installedModules?.some((m) => m.meta?.name === "@nuxt/image");
81
141
  if (imageAlreadyLoaded) {
82
142
  await installModule("@nuxt/image");
@@ -92,12 +152,21 @@ const module = defineNuxtModule({
92
152
  `export const storageName = ${JSON.stringify(options.storageName)};`,
93
153
  `export const storagePath = ${JSON.stringify(options.storagePath)};`
94
154
  ].join("\n");
95
- 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");
159
+ nitroConfig.virtual["#nuxt-filer-tus"] = [
160
+ `export const tusRoute = ${JSON.stringify(tusRoute)};`,
161
+ `export const tusStagingDir = ${JSON.stringify(tusStagingDir)};`,
162
+ `export const tusMaxSize = ${JSON.stringify(tusOpt.maxSize ?? 0)};`,
163
+ `export const tusExpiration = ${JSON.stringify(tusOpt.expiration ?? 0)};`
164
+ ].join("\n");
165
+ nitroConfig.plugins = nitroConfig.plugins || [];
166
+ nitroConfig.plugins.push(
167
+ resolver.resolve("./runtime/server/plugins/default-storage")
168
+ );
96
169
  if (options.provider === "unstorage") {
97
- nitroConfig.plugins = nitroConfig.plugins || [];
98
- nitroConfig.plugins.push(
99
- resolver.resolve("./runtime/server/plugins/default-storage")
100
- );
101
170
  nitroConfig.plugins.push(
102
171
  resolver.resolve("./runtime/server/plugins/default-provider")
103
172
  );
@@ -0,0 +1,18 @@
1
+ import type { TusUploadState, UseTusUploadOptions } from '../types.js';
2
+ export type { TusUploadState, UseTusUploadOptions };
3
+ /**
4
+ * Resumable uploads against the module's tus endpoint. Files are staged on
5
+ * the server; move them into the file storage afterwards with
6
+ * `useTusStaging().promote()` from one of your own server routes.
7
+ */
8
+ export declare function useTusUpload(options?: UseTusUploadOptions): {
9
+ endpoint: string;
10
+ items: Record<string, TusUploadState>;
11
+ uploading: import("vue").ComputedRef<boolean>;
12
+ completed: import("vue").ComputedRef<TusUploadState[]>;
13
+ start: (file: File) => Promise<TusUploadState>;
14
+ add: (files: File | File[]) => void;
15
+ remove: (file: File | string) => Promise<void>;
16
+ clear: () => void;
17
+ cancel: () => Promise<void>;
18
+ };
@@ -0,0 +1,137 @@
1
+ import { computed, reactive, getCurrentScope, onScopeDispose } from "vue";
2
+ import { useRuntimeConfig } from "nuxt/app";
3
+ import { Upload as TusUpload } from "tus-js-client";
4
+ const DEFAULT_RETRY_DELAYS = [0, 3e3, 5e3, 1e4, 2e4];
5
+ export function useTusUpload(options = {}) {
6
+ const runtimeConfig = useRuntimeConfig();
7
+ const endpoint = options.endpoint ?? runtimeConfig.public.filer?.tusRoute ?? "/_filer-tus";
8
+ const items = reactive({});
9
+ const instances = /* @__PURE__ */ new Map();
10
+ const uploading = computed(
11
+ () => Object.values(items).some((item) => !item.complete && !item.error)
12
+ );
13
+ const completed = computed(
14
+ () => Object.values(items).filter((item) => item.complete)
15
+ );
16
+ function captureUploadUrl(state, upload) {
17
+ if (upload.url && !state.tusId) {
18
+ state.uploadUrl = upload.url;
19
+ state.tusId = upload.url.split("/").pop();
20
+ }
21
+ }
22
+ async function start(file) {
23
+ const key = file.name;
24
+ const state = { file, progress: 0, complete: false };
25
+ items[key] = state;
26
+ const upload = new TusUpload(file, {
27
+ endpoint,
28
+ retryDelays: options.retryDelays ?? DEFAULT_RETRY_DELAYS,
29
+ chunkSize: options.chunkSize,
30
+ removeFingerprintOnSuccess: true,
31
+ metadata: {
32
+ filename: file.name,
33
+ filetype: file.type,
34
+ ...options.metadata?.(file)
35
+ },
36
+ onError(error) {
37
+ const item = items[key];
38
+ if (!item) return;
39
+ item.error = error instanceof Error ? error.message : String(error);
40
+ options.onError?.(file, error);
41
+ },
42
+ onProgress(bytesUploaded, bytesTotal) {
43
+ const item = items[key];
44
+ if (!item) return;
45
+ item.progress = bytesTotal ? bytesUploaded / bytesTotal * 100 : 0;
46
+ captureUploadUrl(item, upload);
47
+ },
48
+ onSuccess() {
49
+ const item = items[key];
50
+ if (!item) return;
51
+ captureUploadUrl(item, upload);
52
+ item.progress = 100;
53
+ item.complete = true;
54
+ options.onSuccess?.(file, item);
55
+ }
56
+ });
57
+ instances.set(key, upload);
58
+ if (options.resume !== false) {
59
+ const previous = await upload.findPreviousUploads().catch(() => []);
60
+ if (previous[0]) upload.resumeFromPreviousUpload(previous[0]);
61
+ }
62
+ upload.start();
63
+ return state;
64
+ }
65
+ function add(files) {
66
+ for (const file of Array.isArray(files) ? files : [files]) {
67
+ if (!items[file.name]) void start(file);
68
+ }
69
+ }
70
+ async function remove(file) {
71
+ const key = typeof file === "string" ? file : file.name;
72
+ const state = items[key];
73
+ const upload = instances.get(key);
74
+ instances.delete(key);
75
+ Reflect.deleteProperty(items, key);
76
+ if (upload) {
77
+ await upload.abort(true).catch(() => {
78
+ });
79
+ } else if (state?.uploadUrl) {
80
+ await fetch(state.uploadUrl, {
81
+ method: "DELETE",
82
+ headers: { "Tus-Resumable": "1.0.0" }
83
+ }).catch(() => {
84
+ });
85
+ }
86
+ }
87
+ function clear() {
88
+ for (const upload of instances.values()) {
89
+ void upload.abort().catch(() => {
90
+ });
91
+ }
92
+ instances.clear();
93
+ for (const key of Object.keys(items)) Reflect.deleteProperty(items, key);
94
+ }
95
+ async function cancel() {
96
+ await Promise.allSettled(Object.keys(items).map((key) => remove(key)));
97
+ }
98
+ if (import.meta.client) {
99
+ const onOnline = () => {
100
+ for (const [key, upload] of instances) {
101
+ const item = items[key];
102
+ if (item && !item.complete) {
103
+ item.error = void 0;
104
+ upload.start();
105
+ }
106
+ }
107
+ };
108
+ const onPageHide = () => {
109
+ if (!options.cleanupOnPageHide) return;
110
+ const tusIds = Object.values(items).map((item) => item.tusId).filter((id) => !!id);
111
+ if (tusIds.length === 0) return;
112
+ navigator.sendBeacon(
113
+ `${endpoint}/cleanup`,
114
+ new Blob([JSON.stringify({ tusIds })], { type: "application/json" })
115
+ );
116
+ };
117
+ window.addEventListener("online", onOnline);
118
+ window.addEventListener("pagehide", onPageHide);
119
+ if (getCurrentScope()) {
120
+ onScopeDispose(() => {
121
+ window.removeEventListener("online", onOnline);
122
+ window.removeEventListener("pagehide", onPageHide);
123
+ });
124
+ }
125
+ }
126
+ return {
127
+ endpoint,
128
+ items,
129
+ uploading,
130
+ completed,
131
+ start,
132
+ add,
133
+ remove,
134
+ clear,
135
+ cancel
136
+ };
137
+ }
@@ -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
+ });