nuxt-filer 0.0.14 → 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 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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-filer",
3
3
  "configKey": "filer",
4
- "version": "0.0.14",
4
+ "version": "0.0.16",
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);
@@ -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 imageConfig = nuxt.options.image ?? {};
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
- nuxt.options.image = imageConfig;
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,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<Response | null>>;
2
+ export default _default;
@@ -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,4 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ async function mapWithConcurrency(items, limit, fn) {
3
+ const results = Array.from({ length: items.length });
4
+ let next = 0;
5
+ const worker = async () => {
6
+ while (next < items.length) {
7
+ const index = next++;
8
+ results[index] = await fn(items[index], index);
9
+ }
10
+ };
11
+ const workers = Array.from({ length: Math.min(limit, items.length) || 1 }, worker);
12
+ await Promise.all(workers);
13
+ return results;
14
+ }
2
15
  export function createS3Provider(options) {
3
16
  const prefix = options.prefix ? options.prefix.replace(/\/+$/, "") + "/" : "";
4
17
  let clientPromise;
@@ -67,11 +80,14 @@ export function createS3Provider(options) {
67
80
  },
68
81
  async list(groupId) {
69
82
  const client = await getClient();
83
+ const keys = [];
84
+ for await (const key of client.listKeys(metaPrefix(groupId))) keys.push(key);
85
+ const metas = await mapWithConcurrency(keys, 32, (key) => readMeta(client, key));
70
86
  const files = [];
71
- for await (const key of client.listKeys(metaPrefix(groupId))) {
72
- const meta = await readMeta(client, key);
87
+ for (let i = 0; i < keys.length; i++) {
88
+ const meta = metas[i];
73
89
  if (!meta) continue;
74
- files.push(toStoredFile(idFromKey(key), groupId, meta));
90
+ files.push(toStoredFile(idFromKey(keys[i]), groupId, meta));
75
91
  }
76
92
  return files;
77
93
  },
@@ -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
+ }
@@ -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
@@ -1,3 +1,3 @@
1
1
  export { default } from './module.mjs'
2
2
 
3
- export { type FilerImageOptions, type ModuleOptions } from './module.mjs'
3
+ export { type FilerImageOptions, type FilerTusOptions, type ModuleOptions } from './module.mjs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-filer",
3
- "version": "0.0.14",
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
- "scripts": {
83
- "dev": "npm run dev:prepare && nuxi dev playground",
84
- "dev:build": "nuxi build playground",
85
- "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
86
- "release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
87
- "lint": "eslint .",
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
+ });
@@ -38,6 +38,31 @@ export interface S3ProviderOptions {
38
38
 
39
39
  type InternalMeta = FileMeta & { _createdAt?: string; _updatedAt?: string };
40
40
 
41
+ /**
42
+ * Map over `items` with at most `limit` promises in flight, preserving order.
43
+ * Used to read many meta objects concurrently instead of one-at-a-time — on a
44
+ * remote store (R2/S3) the per-object round-trip latency dominates, so a
45
+ * sequential `for await` over N files is ~N × RTT. Bounded so a huge group
46
+ * doesn't open thousands of sockets at once.
47
+ */
48
+ async function mapWithConcurrency<T, R>(
49
+ items: T[],
50
+ limit: number,
51
+ fn: (item: T, index: number) => Promise<R>,
52
+ ): Promise<R[]> {
53
+ const results: R[] = Array.from({ length: items.length });
54
+ let next = 0;
55
+ const worker = async () => {
56
+ while (next < items.length) {
57
+ const index = next++;
58
+ results[index] = await fn(items[index]!, index);
59
+ }
60
+ };
61
+ const workers = Array.from({ length: Math.min(limit, items.length) || 1 }, worker);
62
+ await Promise.all(workers);
63
+ return results;
64
+ }
65
+
41
66
  /**
42
67
  * S3-backed {@link FileStorageProvider}. Binary data and a JSON metadata
43
68
  * sidecar are stored as separate objects per file, mirroring the built-in
@@ -143,11 +168,17 @@ export function createS3Provider(options: S3ProviderOptions): FileStorageProvide
143
168
 
144
169
  async list(groupId) {
145
170
  const client = await getClient();
171
+ // Enumerate the group's meta keys (cheap paginated LIST), then read every
172
+ // meta object in parallel. The previous sequential read was one blocking
173
+ // GET per file, so listing a group cost ~N round-trips to the store.
174
+ const keys: string[] = [];
175
+ for await (const key of client.listKeys(metaPrefix(groupId))) keys.push(key);
176
+ const metas = await mapWithConcurrency(keys, 32, (key) => readMeta(client, key));
146
177
  const files: StoredFile[] = [];
147
- for await (const key of client.listKeys(metaPrefix(groupId))) {
148
- const meta = await readMeta(client, key);
178
+ for (let i = 0; i < keys.length; i++) {
179
+ const meta = metas[i];
149
180
  if (!meta) continue;
150
- files.push(toStoredFile(idFromKey(key), groupId, meta));
181
+ files.push(toStoredFile(idFromKey(keys[i]!), groupId, meta));
151
182
  }
152
183
  return files;
153
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
+ }
@@ -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;