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.
- package/README.md +209 -4
- package/dist/module.d.mts +37 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +82 -13
- package/dist/runtime/composables/tus.d.ts +18 -0
- package/dist/runtime/composables/tus.js +137 -0
- package/dist/runtime/server/handlers/image-service.d.ts +12 -0
- package/dist/runtime/server/handlers/image-service.js +21 -0
- package/dist/runtime/server/handlers/ipx.js +32 -14
- package/dist/runtime/server/handlers/tus.d.ts +2 -0
- package/dist/runtime/server/handlers/tus.js +36 -0
- package/dist/runtime/server/plugins/default-storage.d.ts +2 -1
- package/dist/runtime/server/plugins/default-storage.js +2 -0
- package/dist/runtime/server/providers/drizzle.d.ts +75 -0
- package/dist/runtime/server/providers/drizzle.js +225 -0
- package/dist/runtime/server/providers/s3.d.ts +5 -0
- package/dist/runtime/server/providers/s3.js +15 -3
- package/dist/runtime/server/utils/image-service-runtime.d.ts +9 -0
- package/dist/runtime/server/utils/image-service-runtime.js +65 -0
- package/dist/runtime/server/utils/image-service.d.ts +36 -0
- package/dist/runtime/server/utils/image-service.js +113 -0
- package/dist/runtime/server/utils/storage.js +3 -1
- package/dist/runtime/server/utils/tus.d.ts +33 -0
- package/dist/runtime/server/utils/tus.js +132 -0
- package/dist/runtime/types.d.ts +51 -0
- package/dist/types.d.mts +1 -1
- package/package.json +19 -8
- package/src/runtime/composables/tus.ts +173 -0
- package/src/runtime/server/handlers/image-service.ts +35 -0
- package/src/runtime/server/handlers/ipx.ts +55 -20
- package/src/runtime/server/handlers/tus.ts +60 -0
- package/src/runtime/server/plugins/default-storage.ts +6 -1
- package/src/runtime/server/providers/drizzle.ts +379 -0
- package/src/runtime/server/providers/s3.ts +24 -5
- package/src/runtime/server/utils/image-service-runtime.ts +87 -0
- package/src/runtime/server/utils/image-service.ts +166 -0
- package/src/runtime/server/utils/image.ts +2 -2
- package/src/runtime/server/utils/storage.ts +8 -3
- package/src/runtime/server/utils/tus.ts +192 -0
- 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** —
|
|
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 (
|
|
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
package/dist/module.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { defineNuxtModule, createResolver, addServerImports, addImports,
|
|
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
|
|
66
|
-
if (
|
|
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/
|
|
119
|
+
handler: resolver.resolve("./runtime/server/handlers/image-service")
|
|
70
120
|
});
|
|
71
|
-
|
|
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
|
-
|
|
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"] =
|
|
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
|
+
});
|