nuxt-filer 0.0.15 → 0.0.16
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 +97 -0
- package/dist/module.d.mts +18 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +42 -5
- package/dist/runtime/composables/tus.d.ts +18 -0
- package/dist/runtime/composables/tus.js +137 -0
- package/dist/runtime/server/handlers/tus.d.ts +2 -0
- package/dist/runtime/server/handlers/tus.js +36 -0
- package/dist/runtime/server/providers/s3.js +1 -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 +24 -13
- package/src/runtime/composables/tus.ts +173 -0
- package/src/runtime/server/handlers/tus.ts +60 -0
- package/src/runtime/server/providers/s3.ts +3 -3
- package/src/runtime/server/utils/tus.ts +192 -0
- package/src/runtime/types.ts +54 -0
package/README.md
CHANGED
|
@@ -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
|
|
|
@@ -193,6 +194,102 @@ filer: {
|
|
|
193
194
|
|
|
194
195
|
`@nuxt/image` and `ipx` are declared as optional peer dependencies — they only need to be installed if you want to use this integration.
|
|
195
196
|
|
|
197
|
+
## Resumable uploads (tus)
|
|
198
|
+
|
|
199
|
+
Large or flaky-network uploads can use the [tus protocol](https://tus.io)
|
|
200
|
+
instead of a single multipart POST. Uploads are staged chunk-by-chunk into a
|
|
201
|
+
local directory (survives connection drops and page reloads), then *promoted*
|
|
202
|
+
into the regular file storage by one of your own server routes — which is
|
|
203
|
+
where you enforce auth and attach domain metadata.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
// nuxt.config.ts
|
|
207
|
+
export default defineNuxtConfig({
|
|
208
|
+
modules: ['nuxt-filer'],
|
|
209
|
+
filer: {
|
|
210
|
+
tus: {
|
|
211
|
+
enabled: true,
|
|
212
|
+
route: '/_filer-tus', // default
|
|
213
|
+
stagingDir: '.data/tus', // default
|
|
214
|
+
maxSize: 500 * 1024 * 1024, // optional, bytes
|
|
215
|
+
expiration: 24 * 60 * 60 * 1000, // optional: purge stale staged uploads
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Client side, the auto-imported `useTusUpload()` composable wraps
|
|
222
|
+
[`tus-js-client`](https://github.com/tus/tus-js-client) with reactive state:
|
|
223
|
+
|
|
224
|
+
```vue
|
|
225
|
+
<script setup lang="ts">
|
|
226
|
+
const tus = useTusUpload({
|
|
227
|
+
metadata: (file) => ({ comment: 'from the web app' }), // extra tus metadata
|
|
228
|
+
// cleanupOnPageHide: true, // sendBeacon-delete staged uploads on close
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
function onSelect(e: Event) {
|
|
232
|
+
tus.add(Array.from((e.target as HTMLInputElement).files ?? []))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function save() {
|
|
236
|
+
for (const item of tus.completed.value) {
|
|
237
|
+
await $fetch('/api/documents/finalize', {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
body: { tusId: item.tusId, name: item.file.name },
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
tus.clear()
|
|
243
|
+
}
|
|
244
|
+
</script>
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Each entry in `tus.items` tracks `progress`, `complete`, `tusId`, and `error`;
|
|
248
|
+
`tus.remove(name)` aborts and deletes a staged upload, `tus.cancel()` discards
|
|
249
|
+
everything. Interrupted uploads resume automatically (retry backoff, restart
|
|
250
|
+
on `online`, and — via the tus fingerprint — across page reloads).
|
|
251
|
+
|
|
252
|
+
Server side, promote a finished upload into the file storage:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
// server/api/documents/finalize.post.ts
|
|
256
|
+
export default defineEventHandler(async (event) => {
|
|
257
|
+
const body = await readBody(event)
|
|
258
|
+
|
|
259
|
+
const { id, meta } = await useTusStaging().promote(body.tusId, 'my-group', {
|
|
260
|
+
meta: { type: 'document' }, // merged over tus filename/filetype
|
|
261
|
+
// transform: { width: 1600 }, // optional sharp processing
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
return { id, meta }
|
|
265
|
+
})
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`useTusStaging()` also exposes `info()`, `read()`, and `remove()` for staged
|
|
269
|
+
uploads. To protect or customize the endpoint itself (auth, upload hooks, a
|
|
270
|
+
different datastore), configure the underlying `@tus/server` from a Nitro
|
|
271
|
+
plugin — the server is created lazily on the first request:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
// server/plugins/tus.ts
|
|
275
|
+
export default defineNitroPlugin(() => {
|
|
276
|
+
setTusServerOptions({
|
|
277
|
+
async onIncomingRequest(req) {
|
|
278
|
+
// throw { status_code: 401, body: 'Unauthorized' } to reject
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Notes:
|
|
285
|
+
|
|
286
|
+
- The endpoint handles the full tus lifecycle (create/HEAD/PATCH/DELETE); a
|
|
287
|
+
`POST <route>/cleanup` sub-route accepts `{ tusIds: string[] }` from
|
|
288
|
+
`navigator.sendBeacon` for page-close cleanup (used by `cleanupOnPageHide`).
|
|
289
|
+
- Staging uses `@tus/file-store` on the local filesystem, independent of the
|
|
290
|
+
configured storage provider — promotion works with any provider, including
|
|
291
|
+
S3 and custom ones.
|
|
292
|
+
|
|
196
293
|
## S3 storage
|
|
197
294
|
|
|
198
295
|
For durable object storage (AWS S3, Cloudflare R2, MinIO, …) use the built-in
|
package/dist/module.d.mts
CHANGED
|
@@ -13,6 +13,21 @@ interface FilerImageOptions {
|
|
|
13
13
|
/** Name to register the @nuxt/image provider under. Default: `filer`. */
|
|
14
14
|
providerName?: string;
|
|
15
15
|
}
|
|
16
|
+
interface FilerTusOptions {
|
|
17
|
+
/** Enable the tus endpoint + composable. Defaults to `true` when the `tus` option is an object. */
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
/** Base route of the tus endpoint. Default: `/_filer-tus`. */
|
|
20
|
+
route?: string;
|
|
21
|
+
/** Directory where in-progress uploads are staged. Default: `.data/tus`. */
|
|
22
|
+
stagingDir?: string;
|
|
23
|
+
/** Max upload size in bytes. Default: unlimited. */
|
|
24
|
+
maxSize?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Staged uploads older than this (ms) are expired: reported to clients via
|
|
27
|
+
* `Upload-Expires` and purged periodically. Default: never expire.
|
|
28
|
+
*/
|
|
29
|
+
expiration?: number;
|
|
30
|
+
}
|
|
16
31
|
interface ModuleOptions {
|
|
17
32
|
/** Nitro storage mount name for binary file data. Default: 'documents' */
|
|
18
33
|
storageName?: string;
|
|
@@ -22,8 +37,10 @@ interface ModuleOptions {
|
|
|
22
37
|
provider?: 'unstorage' | 'custom';
|
|
23
38
|
/** @nuxt/image integration. Set to `false` to disable, or pass an object to override defaults. */
|
|
24
39
|
image?: boolean | FilerImageOptions;
|
|
40
|
+
/** Resumable uploads via the tus protocol. Opt-in: pass `true` or an options object. */
|
|
41
|
+
tus?: boolean | FilerTusOptions;
|
|
25
42
|
}
|
|
26
43
|
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
27
44
|
|
|
28
45
|
export { _default as default };
|
|
29
|
-
export type { FilerImageOptions, ModuleOptions };
|
|
46
|
+
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);
|
|
@@ -56,8 +58,36 @@ const module = defineNuxtModule({
|
|
|
56
58
|
},
|
|
57
59
|
{ name: "ImageFormat", from: typesSpecifier, type: true },
|
|
58
60
|
{ name: "ImageTransformOptions", from: typesSpecifier, type: true },
|
|
59
|
-
{ name: "ImageTransformResult", from: typesSpecifier, type: true }
|
|
61
|
+
{ name: "ImageTransformResult", from: typesSpecifier, type: true },
|
|
62
|
+
{ name: "TusUploadState", from: typesSpecifier, type: true },
|
|
63
|
+
{ name: "UseTusUploadOptions", from: typesSpecifier, type: true },
|
|
64
|
+
{ name: "TusPromoteOptions", from: typesSpecifier, type: true }
|
|
60
65
|
]);
|
|
66
|
+
const tusOpt = typeof options.tus === "object" && options.tus !== null ? options.tus : {};
|
|
67
|
+
const tusEnabled = options.tus === true || typeof options.tus === "object" && options.tus !== null && tusOpt.enabled !== false;
|
|
68
|
+
const tusRoute = (tusOpt.route ?? "/_filer-tus").replace(/\/+$/, "");
|
|
69
|
+
const tusStagingDir = tusOpt.stagingDir ?? ".data/tus";
|
|
70
|
+
if (tusEnabled) {
|
|
71
|
+
const tusHandler = resolver.resolve("./runtime/server/handlers/tus");
|
|
72
|
+
addServerHandler({ route: tusRoute, handler: tusHandler });
|
|
73
|
+
addServerHandler({ route: `${tusRoute}/**`, handler: tusHandler });
|
|
74
|
+
const tusUtilsSpecifier = resolver.resolve("./runtime/server/utils/tus");
|
|
75
|
+
addServerImports([
|
|
76
|
+
{ name: "useTusServer", from: tusUtilsSpecifier },
|
|
77
|
+
{ name: "setTusServerOptions", from: tusUtilsSpecifier },
|
|
78
|
+
{ name: "useTusStaging", from: tusUtilsSpecifier }
|
|
79
|
+
]);
|
|
80
|
+
addImports([
|
|
81
|
+
{
|
|
82
|
+
name: "useTusUpload",
|
|
83
|
+
from: resolver.resolve("./runtime/composables/tus")
|
|
84
|
+
}
|
|
85
|
+
]);
|
|
86
|
+
nuxt.options.runtimeConfig.public.filer = defu(
|
|
87
|
+
nuxt.options.runtimeConfig.public.filer,
|
|
88
|
+
{ tusRoute }
|
|
89
|
+
);
|
|
90
|
+
}
|
|
61
91
|
const imageOpt = typeof options.image === "object" && options.image !== null ? options.image : {};
|
|
62
92
|
const imageEnabled = options.image !== false && (imageOpt.enabled ?? true) !== false;
|
|
63
93
|
const ipxRoute = (imageOpt.route ?? "/_filer-ipx").replace(/\/+$/, "");
|
|
@@ -68,7 +98,8 @@ const module = defineNuxtModule({
|
|
|
68
98
|
route: `${ipxRoute}/**`,
|
|
69
99
|
handler: resolver.resolve("./runtime/server/handlers/ipx")
|
|
70
100
|
});
|
|
71
|
-
const
|
|
101
|
+
const optionsWithImage = nuxt.options;
|
|
102
|
+
const imageConfig = optionsWithImage.image ?? {};
|
|
72
103
|
const providers = imageConfig.providers ?? {};
|
|
73
104
|
providers[providerName] = {
|
|
74
105
|
name: providerName,
|
|
@@ -76,7 +107,7 @@ const module = defineNuxtModule({
|
|
|
76
107
|
options: { baseURL: ipxRoute }
|
|
77
108
|
};
|
|
78
109
|
imageConfig.providers = providers;
|
|
79
|
-
|
|
110
|
+
optionsWithImage.image = imageConfig;
|
|
80
111
|
const imageAlreadyLoaded = nuxt.options._installedModules?.some((m) => m.meta?.name === "@nuxt/image");
|
|
81
112
|
if (imageAlreadyLoaded) {
|
|
82
113
|
await installModule("@nuxt/image");
|
|
@@ -93,6 +124,12 @@ const module = defineNuxtModule({
|
|
|
93
124
|
`export const storagePath = ${JSON.stringify(options.storagePath)};`
|
|
94
125
|
].join("\n");
|
|
95
126
|
nitroConfig.virtual["#nuxt-filer-image"] = `export const ipxRoute = ${JSON.stringify(ipxRoute)}`;
|
|
127
|
+
nitroConfig.virtual["#nuxt-filer-tus"] = [
|
|
128
|
+
`export const tusRoute = ${JSON.stringify(tusRoute)};`,
|
|
129
|
+
`export const tusStagingDir = ${JSON.stringify(tusStagingDir)};`,
|
|
130
|
+
`export const tusMaxSize = ${JSON.stringify(tusOpt.maxSize ?? 0)};`,
|
|
131
|
+
`export const tusExpiration = ${JSON.stringify(tusOpt.expiration ?? 0)};`
|
|
132
|
+
].join("\n");
|
|
96
133
|
if (options.provider === "unstorage") {
|
|
97
134
|
nitroConfig.plugins = nitroConfig.plugins || [];
|
|
98
135
|
nitroConfig.plugins.push(
|
|
@@ -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,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEventHandler,
|
|
3
|
+
toWebRequest,
|
|
4
|
+
readBody,
|
|
5
|
+
setResponseStatus,
|
|
6
|
+
createError
|
|
7
|
+
} from "h3";
|
|
8
|
+
import { tusRoute } from "#nuxt-filer-tus";
|
|
9
|
+
import { useTusServer, useTusStaging, isSafeTusId } from "../utils/tus.js";
|
|
10
|
+
async function handleCleanupBeacon(event) {
|
|
11
|
+
const body = await readBody(event).catch(() => null);
|
|
12
|
+
const tusIds = Array.isArray(body?.tusIds) ? body.tusIds.filter(isSafeTusId).slice(0, 100) : [];
|
|
13
|
+
const hook = useTusServer().options.onIncomingRequest;
|
|
14
|
+
if (hook) {
|
|
15
|
+
const request = toWebRequest(event);
|
|
16
|
+
for (const tusId of tusIds) {
|
|
17
|
+
try {
|
|
18
|
+
await hook(request, tusId);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
const status = error.status_code ?? error.statusCode ?? 500;
|
|
21
|
+
throw createError({ statusCode: status, statusMessage: "Cleanup rejected" });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const staging = useTusStaging();
|
|
26
|
+
await Promise.allSettled(tusIds.map((id) => staging.remove(id)));
|
|
27
|
+
setResponseStatus(event, 204);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export default defineEventHandler(async (event) => {
|
|
31
|
+
const pathname = event.path.split("?")[0];
|
|
32
|
+
if (event.method === "POST" && pathname === `${tusRoute}/cleanup`) {
|
|
33
|
+
return handleCleanupBeacon(event);
|
|
34
|
+
}
|
|
35
|
+
return useTusServer().handleWeb(toWebRequest(event));
|
|
36
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
async function mapWithConcurrency(items, limit, fn) {
|
|
3
|
-
const results =
|
|
3
|
+
const results = Array.from({ length: items.length });
|
|
4
4
|
let next = 0;
|
|
5
5
|
const worker = async () => {
|
|
6
6
|
while (next < items.length) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Server, type ServerOptions, type DataStore, type Upload } from '@tus/server';
|
|
2
|
+
import type { FileMeta, TusPromoteOptions } from '../../../runtime/types.js';
|
|
3
|
+
export type { TusPromoteOptions };
|
|
4
|
+
/**
|
|
5
|
+
* Everything of `ServerOptions` except `path` (owned by the module config)
|
|
6
|
+
* can be customized, plus the datastore itself. Note that `useTusStaging()`
|
|
7
|
+
* requires a datastore with a `read()` method (like the default FileStore).
|
|
8
|
+
*/
|
|
9
|
+
export type TusServerUserOptions = Partial<Omit<ServerOptions, 'path'>> & {
|
|
10
|
+
datastore?: DataStore;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Configure the tus server (auth via `onIncomingRequest`, `onUploadFinish`,
|
|
14
|
+
* a custom datastore, ...). Must be called before the first upload request —
|
|
15
|
+
* a Nitro plugin is the right place.
|
|
16
|
+
*/
|
|
17
|
+
export declare function setTusServerOptions(options: TusServerUserOptions): void;
|
|
18
|
+
export declare function useTusServer(): Server;
|
|
19
|
+
/** Guards datastore ids used in file paths against traversal. */
|
|
20
|
+
export declare function isSafeTusId(id: unknown): id is string;
|
|
21
|
+
/**
|
|
22
|
+
* Work with uploads staged by the tus endpoint: inspect, read, remove, or
|
|
23
|
+
* `promote()` them into the regular file storage.
|
|
24
|
+
*/
|
|
25
|
+
export declare function useTusStaging(): {
|
|
26
|
+
info: (tusId: string) => Promise<Upload | null>;
|
|
27
|
+
read: (tusId: string) => Promise<Buffer | null>;
|
|
28
|
+
remove: (tusId: string) => Promise<boolean>;
|
|
29
|
+
promote: (tusId: string, groupId: string, options?: TusPromoteOptions) => Promise<{
|
|
30
|
+
id: string;
|
|
31
|
+
meta: FileMeta;
|
|
32
|
+
}>;
|
|
33
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createError } from "h3";
|
|
2
|
+
import { defu } from "defu";
|
|
3
|
+
import { consola } from "consola";
|
|
4
|
+
import {
|
|
5
|
+
Server
|
|
6
|
+
} from "@tus/server";
|
|
7
|
+
import { FileStore } from "@tus/file-store";
|
|
8
|
+
import { tusRoute, tusStagingDir, tusMaxSize, tusExpiration } from "#nuxt-filer-tus";
|
|
9
|
+
import { useFileStorage } from "./storage.js";
|
|
10
|
+
let userOptions = {};
|
|
11
|
+
let server;
|
|
12
|
+
let datastore;
|
|
13
|
+
let expirationTimer;
|
|
14
|
+
export function setTusServerOptions(options) {
|
|
15
|
+
if (server) {
|
|
16
|
+
consola.warn(
|
|
17
|
+
"nuxt-filer: setTusServerOptions() called after the tus server was created \u2014 the options are ignored. Call it from a Nitro plugin instead."
|
|
18
|
+
);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
userOptions = options;
|
|
22
|
+
}
|
|
23
|
+
function useTusDatastore() {
|
|
24
|
+
if (!datastore) {
|
|
25
|
+
datastore = userOptions.datastore ?? new FileStore({
|
|
26
|
+
directory: tusStagingDir,
|
|
27
|
+
expirationPeriodInMilliseconds: tusExpiration > 0 ? tusExpiration : void 0
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return datastore;
|
|
31
|
+
}
|
|
32
|
+
export function useTusServer() {
|
|
33
|
+
if (!server) {
|
|
34
|
+
const { datastore: _datastore, ...serverOptions } = userOptions;
|
|
35
|
+
server = new Server({
|
|
36
|
+
path: tusRoute,
|
|
37
|
+
respectForwardedHeaders: true,
|
|
38
|
+
maxSize: tusMaxSize > 0 ? tusMaxSize : void 0,
|
|
39
|
+
datastore: useTusDatastore(),
|
|
40
|
+
...serverOptions
|
|
41
|
+
});
|
|
42
|
+
if (tusExpiration > 0 && !expirationTimer) {
|
|
43
|
+
const instance = server;
|
|
44
|
+
expirationTimer = setInterval(
|
|
45
|
+
() => instance.cleanUpExpiredUploads().catch(() => {
|
|
46
|
+
}),
|
|
47
|
+
Math.min(tusExpiration, 60 * 60 * 1e3)
|
|
48
|
+
);
|
|
49
|
+
expirationTimer.unref?.();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return server;
|
|
53
|
+
}
|
|
54
|
+
export function isSafeTusId(id) {
|
|
55
|
+
return typeof id === "string" && id.length > 0 && id.length <= 255 && !id.includes("/") && !id.includes("\\") && !id.includes("..");
|
|
56
|
+
}
|
|
57
|
+
export function useTusStaging() {
|
|
58
|
+
const store = useTusDatastore();
|
|
59
|
+
async function info(tusId) {
|
|
60
|
+
if (!isSafeTusId(tusId)) return null;
|
|
61
|
+
try {
|
|
62
|
+
return await store.getUpload(tusId);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function read(tusId) {
|
|
68
|
+
if (!isSafeTusId(tusId)) return null;
|
|
69
|
+
const readable = store;
|
|
70
|
+
if (typeof readable.read !== "function") {
|
|
71
|
+
throw new TypeError(
|
|
72
|
+
"nuxt-filer: the configured tus datastore does not support read() \u2014 useTusStaging() requires a FileStore-compatible datastore"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const chunks = [];
|
|
77
|
+
for await (const chunk of readable.read(tusId)) {
|
|
78
|
+
chunks.push(chunk);
|
|
79
|
+
}
|
|
80
|
+
return Buffer.concat(chunks);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function remove(tusId) {
|
|
86
|
+
if (!isSafeTusId(tusId)) return false;
|
|
87
|
+
try {
|
|
88
|
+
await store.remove(tusId);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function promote(tusId, groupId, options = {}) {
|
|
95
|
+
const upload = await info(tusId);
|
|
96
|
+
if (!upload) {
|
|
97
|
+
throw createError({
|
|
98
|
+
statusCode: 404,
|
|
99
|
+
statusMessage: `No staged tus upload found for id: ${tusId}`
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (typeof upload.size === "number" && upload.offset !== upload.size) {
|
|
103
|
+
throw createError({
|
|
104
|
+
statusCode: 409,
|
|
105
|
+
statusMessage: `Staged tus upload is incomplete: ${tusId} (${upload.offset}/${upload.size} bytes)`
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const data = await read(tusId);
|
|
109
|
+
if (!data) {
|
|
110
|
+
throw createError({
|
|
111
|
+
statusCode: 404,
|
|
112
|
+
statusMessage: `Staged tus upload data is missing for id: ${tusId}`
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const tusMeta = upload.metadata ?? {};
|
|
116
|
+
const meta = defu(options.meta ?? {}, {
|
|
117
|
+
name: tusMeta.filename ?? tusId,
|
|
118
|
+
mime: tusMeta.filetype ?? "application/octet-stream",
|
|
119
|
+
type: "",
|
|
120
|
+
version: 1
|
|
121
|
+
});
|
|
122
|
+
const id = await useFileStorage().upload(groupId, data, {
|
|
123
|
+
meta,
|
|
124
|
+
transform: options.transform
|
|
125
|
+
});
|
|
126
|
+
if (options.removeStaged !== false) {
|
|
127
|
+
await remove(tusId);
|
|
128
|
+
}
|
|
129
|
+
return { id, meta };
|
|
130
|
+
}
|
|
131
|
+
return { info, read, remove, promote };
|
|
132
|
+
}
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -52,6 +52,57 @@ export interface ImageTransformResult {
|
|
|
52
52
|
/** Height of the processed image in px, if sharp could determine it. */
|
|
53
53
|
height?: number;
|
|
54
54
|
}
|
|
55
|
+
/** Reactive state of a single file tracked by `useTusUpload()`. */
|
|
56
|
+
export interface TusUploadState {
|
|
57
|
+
file: File;
|
|
58
|
+
/** Upload progress in percent (0-100). */
|
|
59
|
+
progress: number;
|
|
60
|
+
/** True once the tus upload finished successfully. */
|
|
61
|
+
complete: boolean;
|
|
62
|
+
/** Id of the staged upload on the server (last segment of `uploadUrl`). */
|
|
63
|
+
tusId?: string;
|
|
64
|
+
/** Full tus upload URL once the server assigned one. */
|
|
65
|
+
uploadUrl?: string;
|
|
66
|
+
/** Message of the last upload error, if any. */
|
|
67
|
+
error?: string;
|
|
68
|
+
}
|
|
69
|
+
export interface UseTusUploadOptions {
|
|
70
|
+
/** tus endpoint. Defaults to the route configured via `filer.tus.route`. */
|
|
71
|
+
endpoint?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Extra tus metadata per file, merged over the default
|
|
74
|
+
* `{ filename, filetype }` pair. Available server-side on the staged
|
|
75
|
+
* upload and used by `useTusStaging().promote()` as meta fallbacks.
|
|
76
|
+
*/
|
|
77
|
+
metadata?: (file: File) => Record<string, string>;
|
|
78
|
+
/** Retry backoff in ms. Default: `[0, 3000, 5000, 10000, 20000]`. */
|
|
79
|
+
retryDelays?: number[];
|
|
80
|
+
/** Fixed chunk size in bytes. Default: let tus-js-client decide. */
|
|
81
|
+
chunkSize?: number;
|
|
82
|
+
/** Resume matching unfinished uploads from a previous session. Default: `true`. */
|
|
83
|
+
resume?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Delete staged uploads via `sendBeacon` when the page is closed while
|
|
86
|
+
* uploads are still tracked (i.e. not yet promoted and `clear()`ed).
|
|
87
|
+
* Trades resumability across page loads for a tidy staging area.
|
|
88
|
+
* Default: `false`.
|
|
89
|
+
*/
|
|
90
|
+
cleanupOnPageHide?: boolean;
|
|
91
|
+
onError?: (file: File, error: Error) => void;
|
|
92
|
+
onSuccess?: (file: File, state: TusUploadState) => void;
|
|
93
|
+
}
|
|
94
|
+
/** Options for `useTusStaging().promote()`. */
|
|
95
|
+
export interface TusPromoteOptions {
|
|
96
|
+
/**
|
|
97
|
+
* Overrides for the stored file's meta. Fields not given fall back to the
|
|
98
|
+
* tus upload metadata (`filename`, `filetype`) and sensible defaults.
|
|
99
|
+
*/
|
|
100
|
+
meta?: Partial<FileMeta>;
|
|
101
|
+
/** Optional upload-time image processing, as in `useFileStorage().upload()`. */
|
|
102
|
+
transform?: ImageTransformOptions;
|
|
103
|
+
/** Remove the staged upload after promoting it. Default: `true`. */
|
|
104
|
+
removeStaged?: boolean;
|
|
105
|
+
}
|
|
55
106
|
export interface ExternalRef {
|
|
56
107
|
/** External system identifier, e.g. 'jira', 'sharepoint' */
|
|
57
108
|
source: string;
|
package/dist/types.d.mts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-filer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"description": "File storage module for Nuxt",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,20 +36,34 @@
|
|
|
36
36
|
"dist",
|
|
37
37
|
"src/runtime"
|
|
38
38
|
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"prepack": "nuxt-module-build prepare && nuxt-module-build build",
|
|
41
|
+
"dev": "npm run dev:prepare && nuxi dev playground",
|
|
42
|
+
"dev:build": "nuxi build playground",
|
|
43
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
|
|
44
|
+
"release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
|
|
45
|
+
"lint": "eslint .",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"test:watch": "vitest watch",
|
|
48
|
+
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
49
|
+
},
|
|
39
50
|
"dependencies": {
|
|
40
51
|
"@nuxt/kit": "^4.4.2",
|
|
52
|
+
"@tus/file-store": "^2.1.0",
|
|
53
|
+
"@tus/server": "^2.4.1",
|
|
41
54
|
"consola": "^3.4.2",
|
|
42
|
-
"defu": "^6.1.4"
|
|
55
|
+
"defu": "^6.1.4",
|
|
56
|
+
"tus-js-client": "^4.3.1"
|
|
43
57
|
},
|
|
44
58
|
"devDependencies": {
|
|
45
59
|
"@nuxt/devtools": "^3.2.4",
|
|
46
60
|
"@nuxt/eslint-config": "^1.15.2",
|
|
47
61
|
"@nuxt/image": "^2.0.0",
|
|
48
|
-
"aws4fetch": "^1.0.20",
|
|
49
62
|
"@nuxt/module-builder": "^1.0.2",
|
|
50
63
|
"@nuxt/schema": "^4.4.2",
|
|
51
64
|
"@nuxt/test-utils": "^4.0.0",
|
|
52
65
|
"@types/node": "latest",
|
|
66
|
+
"aws4fetch": "^1.0.20",
|
|
53
67
|
"changelogen": "^0.6.2",
|
|
54
68
|
"eslint": "^10.1.0",
|
|
55
69
|
"ipx": "^3.1.1",
|
|
@@ -79,14 +93,11 @@
|
|
|
79
93
|
"optional": true
|
|
80
94
|
}
|
|
81
95
|
},
|
|
82
|
-
"
|
|
83
|
-
|
|
84
|
-
"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"test": "vitest run",
|
|
89
|
-
"test:watch": "vitest watch",
|
|
90
|
-
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
96
|
+
"packageManager": "pnpm@10.33.0",
|
|
97
|
+
"pnpm": {
|
|
98
|
+
"onlyBuiltDependencies": [
|
|
99
|
+
"esbuild",
|
|
100
|
+
"sharp"
|
|
101
|
+
]
|
|
91
102
|
}
|
|
92
|
-
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { computed, reactive, getCurrentScope, onScopeDispose } from 'vue';
|
|
2
|
+
import { useRuntimeConfig } from 'nuxt/app';
|
|
3
|
+
import { Upload as TusUpload } from 'tus-js-client';
|
|
4
|
+
import type { TusUploadState, UseTusUploadOptions } from '../types';
|
|
5
|
+
|
|
6
|
+
export type { TusUploadState, UseTusUploadOptions };
|
|
7
|
+
|
|
8
|
+
const DEFAULT_RETRY_DELAYS = [0, 3000, 5000, 10000, 20000];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resumable uploads against the module's tus endpoint. Files are staged on
|
|
12
|
+
* the server; move them into the file storage afterwards with
|
|
13
|
+
* `useTusStaging().promote()` from one of your own server routes.
|
|
14
|
+
*/
|
|
15
|
+
export function useTusUpload(options: UseTusUploadOptions = {}) {
|
|
16
|
+
const runtimeConfig = useRuntimeConfig();
|
|
17
|
+
const endpoint =
|
|
18
|
+
options.endpoint
|
|
19
|
+
?? (runtimeConfig.public.filer as { tusRoute?: string } | undefined)
|
|
20
|
+
?.tusRoute
|
|
21
|
+
?? '/_filer-tus';
|
|
22
|
+
|
|
23
|
+
const items = reactive<Record<string, TusUploadState>>({});
|
|
24
|
+
// tus Upload instances hold DOM/file handles — keep them out of reactivity.
|
|
25
|
+
const instances = new Map<string, TusUpload>();
|
|
26
|
+
|
|
27
|
+
const uploading = computed(() =>
|
|
28
|
+
Object.values(items).some((item) => !item.complete && !item.error)
|
|
29
|
+
);
|
|
30
|
+
const completed = computed(() =>
|
|
31
|
+
Object.values(items).filter((item) => item.complete)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
function captureUploadUrl(state: TusUploadState, upload: TusUpload) {
|
|
35
|
+
if (upload.url && !state.tusId) {
|
|
36
|
+
state.uploadUrl = upload.url;
|
|
37
|
+
state.tusId = upload.url.split('/').pop();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function start(file: File): Promise<TusUploadState> {
|
|
42
|
+
const key = file.name;
|
|
43
|
+
const state: TusUploadState = { file, progress: 0, complete: false };
|
|
44
|
+
items[key] = state;
|
|
45
|
+
|
|
46
|
+
const upload = new TusUpload(file, {
|
|
47
|
+
endpoint,
|
|
48
|
+
retryDelays: options.retryDelays ?? DEFAULT_RETRY_DELAYS,
|
|
49
|
+
chunkSize: options.chunkSize,
|
|
50
|
+
removeFingerprintOnSuccess: true,
|
|
51
|
+
metadata: {
|
|
52
|
+
filename: file.name,
|
|
53
|
+
filetype: file.type,
|
|
54
|
+
...options.metadata?.(file),
|
|
55
|
+
},
|
|
56
|
+
onError(error) {
|
|
57
|
+
const item = items[key];
|
|
58
|
+
if (!item) return;
|
|
59
|
+
item.error = error instanceof Error ? error.message : String(error);
|
|
60
|
+
options.onError?.(file, error as Error);
|
|
61
|
+
},
|
|
62
|
+
onProgress(bytesUploaded, bytesTotal) {
|
|
63
|
+
const item = items[key];
|
|
64
|
+
if (!item) return;
|
|
65
|
+
item.progress = bytesTotal ? (bytesUploaded / bytesTotal) * 100 : 0;
|
|
66
|
+
captureUploadUrl(item, upload);
|
|
67
|
+
},
|
|
68
|
+
onSuccess() {
|
|
69
|
+
const item = items[key];
|
|
70
|
+
if (!item) return;
|
|
71
|
+
captureUploadUrl(item, upload);
|
|
72
|
+
item.progress = 100;
|
|
73
|
+
item.complete = true;
|
|
74
|
+
options.onSuccess?.(file, item);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
instances.set(key, upload);
|
|
78
|
+
|
|
79
|
+
if (options.resume !== false) {
|
|
80
|
+
const previous = await upload.findPreviousUploads().catch(() => []);
|
|
81
|
+
if (previous[0]) upload.resumeFromPreviousUpload(previous[0]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
upload.start();
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Start uploads for files not tracked yet (idempotent per file name). */
|
|
89
|
+
function add(files: File | File[]) {
|
|
90
|
+
for (const file of Array.isArray(files) ? files : [files]) {
|
|
91
|
+
if (!items[file.name]) void start(file);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Abort a file's upload, delete its staged data, and stop tracking it. */
|
|
96
|
+
async function remove(file: File | string) {
|
|
97
|
+
const key = typeof file === 'string' ? file : file.name;
|
|
98
|
+
const state = items[key];
|
|
99
|
+
const upload = instances.get(key);
|
|
100
|
+
instances.delete(key);
|
|
101
|
+
Reflect.deleteProperty(items, key);
|
|
102
|
+
|
|
103
|
+
if (upload) {
|
|
104
|
+
// terminate = tus DELETE; the staged upload may already be gone.
|
|
105
|
+
await upload.abort(true).catch(() => {});
|
|
106
|
+
} else if (state?.uploadUrl) {
|
|
107
|
+
await fetch(state.uploadUrl, {
|
|
108
|
+
method: 'DELETE',
|
|
109
|
+
headers: { 'Tus-Resumable': '1.0.0' },
|
|
110
|
+
}).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stop tracking all files without touching staged data (e.g. after promoting). */
|
|
115
|
+
function clear() {
|
|
116
|
+
for (const upload of instances.values()) {
|
|
117
|
+
void upload.abort().catch(() => {});
|
|
118
|
+
}
|
|
119
|
+
instances.clear();
|
|
120
|
+
for (const key of Object.keys(items)) Reflect.deleteProperty(items, key);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Abort everything and delete all staged uploads. */
|
|
124
|
+
async function cancel() {
|
|
125
|
+
await Promise.allSettled(Object.keys(items).map((key) => remove(key)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (import.meta.client) {
|
|
129
|
+
// Connection loss beyond retryDelays surfaces as an error; restarting the
|
|
130
|
+
// same Upload instance continues from the last confirmed offset.
|
|
131
|
+
const onOnline = () => {
|
|
132
|
+
for (const [key, upload] of instances) {
|
|
133
|
+
const item = items[key];
|
|
134
|
+
if (item && !item.complete) {
|
|
135
|
+
item.error = undefined;
|
|
136
|
+
upload.start();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const onPageHide = () => {
|
|
141
|
+
if (!options.cleanupOnPageHide) return;
|
|
142
|
+
const tusIds = Object.values(items)
|
|
143
|
+
.map((item) => item.tusId)
|
|
144
|
+
.filter((id): id is string => !!id);
|
|
145
|
+
if (tusIds.length === 0) return;
|
|
146
|
+
navigator.sendBeacon(
|
|
147
|
+
`${endpoint}/cleanup`,
|
|
148
|
+
new Blob([JSON.stringify({ tusIds })], { type: 'application/json' })
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
window.addEventListener('online', onOnline);
|
|
153
|
+
window.addEventListener('pagehide', onPageHide);
|
|
154
|
+
if (getCurrentScope()) {
|
|
155
|
+
onScopeDispose(() => {
|
|
156
|
+
window.removeEventListener('online', onOnline);
|
|
157
|
+
window.removeEventListener('pagehide', onPageHide);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
endpoint,
|
|
164
|
+
items,
|
|
165
|
+
uploading,
|
|
166
|
+
completed,
|
|
167
|
+
start,
|
|
168
|
+
add,
|
|
169
|
+
remove,
|
|
170
|
+
clear,
|
|
171
|
+
cancel,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEventHandler,
|
|
3
|
+
toWebRequest,
|
|
4
|
+
readBody,
|
|
5
|
+
setResponseStatus,
|
|
6
|
+
createError,
|
|
7
|
+
type H3Event,
|
|
8
|
+
} from 'h3';
|
|
9
|
+
import type { ServerOptions } from '@tus/server';
|
|
10
|
+
// @ts-expect-error virtual module injected by the module
|
|
11
|
+
import { tusRoute } from '#nuxt-filer-tus';
|
|
12
|
+
import { useTusServer, useTusStaging, isSafeTusId } from '../utils/tus';
|
|
13
|
+
|
|
14
|
+
type IncomingRequestHook = NonNullable<ServerOptions['onIncomingRequest']>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `navigator.sendBeacon` cannot speak tus (no custom methods/headers), so a
|
|
18
|
+
* plain POST sub-route lets a closing page bulk-delete its staged uploads.
|
|
19
|
+
* The configured `onIncomingRequest` hook guards it like any tus request.
|
|
20
|
+
*/
|
|
21
|
+
async function handleCleanupBeacon(event: H3Event) {
|
|
22
|
+
const body = await readBody(event).catch(() => null);
|
|
23
|
+
const tusIds: string[] = Array.isArray(body?.tusIds)
|
|
24
|
+
? body.tusIds.filter(isSafeTusId).slice(0, 100)
|
|
25
|
+
: [];
|
|
26
|
+
|
|
27
|
+
const hook = useTusServer().options.onIncomingRequest as
|
|
28
|
+
| IncomingRequestHook
|
|
29
|
+
| undefined;
|
|
30
|
+
if (hook) {
|
|
31
|
+
const request = toWebRequest(event) as Parameters<IncomingRequestHook>[0];
|
|
32
|
+
for (const tusId of tusIds) {
|
|
33
|
+
try {
|
|
34
|
+
await hook(request, tusId);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const status
|
|
37
|
+
= (error as { status_code?: number; statusCode?: number })
|
|
38
|
+
.status_code
|
|
39
|
+
?? (error as { statusCode?: number }).statusCode
|
|
40
|
+
?? 500;
|
|
41
|
+
throw createError({ statusCode: status, statusMessage: 'Cleanup rejected' });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const staging = useTusStaging();
|
|
47
|
+
await Promise.allSettled(tusIds.map((id) => staging.remove(id)));
|
|
48
|
+
|
|
49
|
+
setResponseStatus(event, 204);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default defineEventHandler(async (event) => {
|
|
54
|
+
const pathname = event.path.split('?')[0];
|
|
55
|
+
if (event.method === 'POST' && pathname === `${tusRoute}/cleanup`) {
|
|
56
|
+
return handleCleanupBeacon(event);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return useTusServer().handleWeb(toWebRequest(event));
|
|
60
|
+
});
|
|
@@ -50,12 +50,12 @@ async function mapWithConcurrency<T, R>(
|
|
|
50
50
|
limit: number,
|
|
51
51
|
fn: (item: T, index: number) => Promise<R>,
|
|
52
52
|
): Promise<R[]> {
|
|
53
|
-
const results =
|
|
53
|
+
const results: R[] = Array.from({ length: items.length });
|
|
54
54
|
let next = 0;
|
|
55
55
|
const worker = async () => {
|
|
56
56
|
while (next < items.length) {
|
|
57
57
|
const index = next++;
|
|
58
|
-
results[index] = await fn(items[index]
|
|
58
|
+
results[index] = await fn(items[index]!, index);
|
|
59
59
|
}
|
|
60
60
|
};
|
|
61
61
|
const workers = Array.from({ length: Math.min(limit, items.length) || 1 }, worker);
|
|
@@ -178,7 +178,7 @@ export function createS3Provider(options: S3ProviderOptions): FileStorageProvide
|
|
|
178
178
|
for (let i = 0; i < keys.length; i++) {
|
|
179
179
|
const meta = metas[i];
|
|
180
180
|
if (!meta) continue;
|
|
181
|
-
files.push(toStoredFile(idFromKey(keys[i]), groupId, meta));
|
|
181
|
+
files.push(toStoredFile(idFromKey(keys[i]!), groupId, meta));
|
|
182
182
|
}
|
|
183
183
|
return files;
|
|
184
184
|
},
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createError } from 'h3';
|
|
2
|
+
import { defu } from 'defu';
|
|
3
|
+
import { consola } from 'consola';
|
|
4
|
+
import {
|
|
5
|
+
Server,
|
|
6
|
+
type ServerOptions,
|
|
7
|
+
type DataStore,
|
|
8
|
+
type Upload,
|
|
9
|
+
} from '@tus/server';
|
|
10
|
+
import { FileStore } from '@tus/file-store';
|
|
11
|
+
// @ts-expect-error virtual module injected by the module
|
|
12
|
+
import { tusRoute, tusStagingDir, tusMaxSize, tusExpiration } from '#nuxt-filer-tus';
|
|
13
|
+
import type { FileMeta, TusPromoteOptions } from '../../../runtime/types';
|
|
14
|
+
import { useFileStorage } from './storage';
|
|
15
|
+
|
|
16
|
+
export type { TusPromoteOptions };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Everything of `ServerOptions` except `path` (owned by the module config)
|
|
20
|
+
* can be customized, plus the datastore itself. Note that `useTusStaging()`
|
|
21
|
+
* requires a datastore with a `read()` method (like the default FileStore).
|
|
22
|
+
*/
|
|
23
|
+
export type TusServerUserOptions = Partial<Omit<ServerOptions, 'path'>> & {
|
|
24
|
+
datastore?: DataStore;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let userOptions: TusServerUserOptions = {};
|
|
28
|
+
let server: Server | undefined;
|
|
29
|
+
let datastore: DataStore | undefined;
|
|
30
|
+
let expirationTimer: ReturnType<typeof setInterval> | undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Configure the tus server (auth via `onIncomingRequest`, `onUploadFinish`,
|
|
34
|
+
* a custom datastore, ...). Must be called before the first upload request —
|
|
35
|
+
* a Nitro plugin is the right place.
|
|
36
|
+
*/
|
|
37
|
+
export function setTusServerOptions(options: TusServerUserOptions) {
|
|
38
|
+
if (server) {
|
|
39
|
+
consola.warn(
|
|
40
|
+
'nuxt-filer: setTusServerOptions() called after the tus server was created — the options are ignored. Call it from a Nitro plugin instead.'
|
|
41
|
+
);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
userOptions = options;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function useTusDatastore(): DataStore {
|
|
48
|
+
if (!datastore) {
|
|
49
|
+
datastore =
|
|
50
|
+
userOptions.datastore
|
|
51
|
+
?? new FileStore({
|
|
52
|
+
directory: tusStagingDir,
|
|
53
|
+
expirationPeriodInMilliseconds:
|
|
54
|
+
tusExpiration > 0 ? tusExpiration : undefined,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return datastore;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useTusServer(): Server {
|
|
61
|
+
if (!server) {
|
|
62
|
+
const { datastore: _datastore, ...serverOptions } = userOptions;
|
|
63
|
+
server = new Server({
|
|
64
|
+
path: tusRoute,
|
|
65
|
+
respectForwardedHeaders: true,
|
|
66
|
+
maxSize: tusMaxSize > 0 ? tusMaxSize : undefined,
|
|
67
|
+
datastore: useTusDatastore(),
|
|
68
|
+
...serverOptions,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (tusExpiration > 0 && !expirationTimer) {
|
|
72
|
+
const instance = server;
|
|
73
|
+
expirationTimer = setInterval(
|
|
74
|
+
() => instance.cleanUpExpiredUploads().catch(() => {}),
|
|
75
|
+
Math.min(tusExpiration, 60 * 60 * 1000)
|
|
76
|
+
);
|
|
77
|
+
expirationTimer.unref?.();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return server;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Guards datastore ids used in file paths against traversal. */
|
|
84
|
+
export function isSafeTusId(id: unknown): id is string {
|
|
85
|
+
return (
|
|
86
|
+
typeof id === 'string'
|
|
87
|
+
&& id.length > 0
|
|
88
|
+
&& id.length <= 255
|
|
89
|
+
&& !id.includes('/')
|
|
90
|
+
&& !id.includes('\\')
|
|
91
|
+
&& !id.includes('..')
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Work with uploads staged by the tus endpoint: inspect, read, remove, or
|
|
97
|
+
* `promote()` them into the regular file storage.
|
|
98
|
+
*/
|
|
99
|
+
export function useTusStaging() {
|
|
100
|
+
const store = useTusDatastore();
|
|
101
|
+
|
|
102
|
+
async function info(tusId: string): Promise<Upload | null> {
|
|
103
|
+
if (!isSafeTusId(tusId)) return null;
|
|
104
|
+
try {
|
|
105
|
+
return await store.getUpload(tusId);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function read(tusId: string): Promise<Buffer | null> {
|
|
112
|
+
if (!isSafeTusId(tusId)) return null;
|
|
113
|
+
const readable = store as Partial<Pick<FileStore, 'read'>>;
|
|
114
|
+
if (typeof readable.read !== 'function') {
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
'nuxt-filer: the configured tus datastore does not support read() — useTusStaging() requires a FileStore-compatible datastore'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const chunks: Buffer[] = [];
|
|
121
|
+
for await (const chunk of readable.read(tusId)) {
|
|
122
|
+
chunks.push(chunk as Buffer);
|
|
123
|
+
}
|
|
124
|
+
return Buffer.concat(chunks);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function remove(tusId: string): Promise<boolean> {
|
|
131
|
+
if (!isSafeTusId(tusId)) return false;
|
|
132
|
+
try {
|
|
133
|
+
await store.remove(tusId);
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Move a completed staged upload into the file storage and (by default)
|
|
142
|
+
* delete the staged copy. Returns the stored file's id and resolved meta.
|
|
143
|
+
*/
|
|
144
|
+
async function promote(
|
|
145
|
+
tusId: string,
|
|
146
|
+
groupId: string,
|
|
147
|
+
options: TusPromoteOptions = {}
|
|
148
|
+
): Promise<{ id: string; meta: FileMeta }> {
|
|
149
|
+
const upload = await info(tusId);
|
|
150
|
+
if (!upload) {
|
|
151
|
+
throw createError({
|
|
152
|
+
statusCode: 404,
|
|
153
|
+
statusMessage: `No staged tus upload found for id: ${tusId}`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (typeof upload.size === 'number' && upload.offset !== upload.size) {
|
|
157
|
+
throw createError({
|
|
158
|
+
statusCode: 409,
|
|
159
|
+
statusMessage: `Staged tus upload is incomplete: ${tusId} (${upload.offset}/${upload.size} bytes)`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const data = await read(tusId);
|
|
164
|
+
if (!data) {
|
|
165
|
+
throw createError({
|
|
166
|
+
statusCode: 404,
|
|
167
|
+
statusMessage: `Staged tus upload data is missing for id: ${tusId}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const tusMeta = upload.metadata ?? {};
|
|
172
|
+
const meta = defu(options.meta ?? {}, {
|
|
173
|
+
name: tusMeta.filename ?? tusId,
|
|
174
|
+
mime: tusMeta.filetype ?? 'application/octet-stream',
|
|
175
|
+
type: '',
|
|
176
|
+
version: 1,
|
|
177
|
+
}) as FileMeta;
|
|
178
|
+
|
|
179
|
+
const id = await useFileStorage().upload(groupId, data, {
|
|
180
|
+
meta,
|
|
181
|
+
transform: options.transform,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (options.removeStaged !== false) {
|
|
185
|
+
await remove(tusId);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { id, meta };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { info, read, remove, promote };
|
|
192
|
+
}
|
package/src/runtime/types.ts
CHANGED
|
@@ -56,6 +56,60 @@ export interface ImageTransformResult {
|
|
|
56
56
|
height?: number;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** Reactive state of a single file tracked by `useTusUpload()`. */
|
|
60
|
+
export interface TusUploadState {
|
|
61
|
+
file: File;
|
|
62
|
+
/** Upload progress in percent (0-100). */
|
|
63
|
+
progress: number;
|
|
64
|
+
/** True once the tus upload finished successfully. */
|
|
65
|
+
complete: boolean;
|
|
66
|
+
/** Id of the staged upload on the server (last segment of `uploadUrl`). */
|
|
67
|
+
tusId?: string;
|
|
68
|
+
/** Full tus upload URL once the server assigned one. */
|
|
69
|
+
uploadUrl?: string;
|
|
70
|
+
/** Message of the last upload error, if any. */
|
|
71
|
+
error?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface UseTusUploadOptions {
|
|
75
|
+
/** tus endpoint. Defaults to the route configured via `filer.tus.route`. */
|
|
76
|
+
endpoint?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Extra tus metadata per file, merged over the default
|
|
79
|
+
* `{ filename, filetype }` pair. Available server-side on the staged
|
|
80
|
+
* upload and used by `useTusStaging().promote()` as meta fallbacks.
|
|
81
|
+
*/
|
|
82
|
+
metadata?: (file: File) => Record<string, string>;
|
|
83
|
+
/** Retry backoff in ms. Default: `[0, 3000, 5000, 10000, 20000]`. */
|
|
84
|
+
retryDelays?: number[];
|
|
85
|
+
/** Fixed chunk size in bytes. Default: let tus-js-client decide. */
|
|
86
|
+
chunkSize?: number;
|
|
87
|
+
/** Resume matching unfinished uploads from a previous session. Default: `true`. */
|
|
88
|
+
resume?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Delete staged uploads via `sendBeacon` when the page is closed while
|
|
91
|
+
* uploads are still tracked (i.e. not yet promoted and `clear()`ed).
|
|
92
|
+
* Trades resumability across page loads for a tidy staging area.
|
|
93
|
+
* Default: `false`.
|
|
94
|
+
*/
|
|
95
|
+
cleanupOnPageHide?: boolean;
|
|
96
|
+
onError?: (file: File, error: Error) => void;
|
|
97
|
+
onSuccess?: (file: File, state: TusUploadState) => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Options for `useTusStaging().promote()`. */
|
|
101
|
+
export interface TusPromoteOptions {
|
|
102
|
+
/**
|
|
103
|
+
* Overrides for the stored file's meta. Fields not given fall back to the
|
|
104
|
+
* tus upload metadata (`filename`, `filetype`) and sensible defaults.
|
|
105
|
+
*/
|
|
106
|
+
meta?: Partial<FileMeta>;
|
|
107
|
+
/** Optional upload-time image processing, as in `useFileStorage().upload()`. */
|
|
108
|
+
transform?: ImageTransformOptions;
|
|
109
|
+
/** Remove the staged upload after promoting it. Default: `true`. */
|
|
110
|
+
removeStaged?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
59
113
|
export interface ExternalRef {
|
|
60
114
|
/** External system identifier, e.g. 'jira', 'sharepoint' */
|
|
61
115
|
source: string;
|