nuxt-filer 0.0.2

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 ADDED
@@ -0,0 +1,231 @@
1
+ # nuxt-filer
2
+
3
+ [![npm version][npm-version-src]][npm-version-href]
4
+ [![npm downloads][npm-downloads-src]][npm-downloads-href]
5
+ [![License][license-src]][license-href]
6
+ [![Nuxt][nuxt-src]][nuxt-href]
7
+
8
+ File storage module for Nuxt. Provides a server-side `useFileStorage()` composable with pluggable storage backends — from zero-config local filesystem to custom providers with separate metadata databases and external file sync.
9
+
10
+ ## Features
11
+
12
+ - **Pluggable provider architecture** — use the built-in unstorage provider or bring your own (Prisma, Drizzle, etc.)
13
+ - **File versioning** — built-in version tracking, latest-version filtering, and duplicate detection
14
+ - **External file sync** — two-way sync with external systems (Jira, SharePoint, etc.) via optional provider interface
15
+ - **Zero-config default** — works out of the box with local filesystem storage, no database required
16
+ - **Auto-imported** — `useFileStorage()`, types, and provider utilities are auto-imported in server context
17
+ - **Group-based organization** — files are organized by `groupId` (project, ticket, order, etc.)
18
+
19
+ ## Quick Setup
20
+
21
+ ```bash
22
+ npx nuxi module add nuxt-filer
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ ```ts
28
+ // nuxt.config.ts
29
+ export default defineNuxtConfig({
30
+ modules: ['nuxt-filer'],
31
+ filer: {
32
+ // Nitro storage mount name (default: 'documents')
33
+ storageName: 'documents',
34
+ // Base path for fs-lite driver (default: '.data/documents')
35
+ storagePath: '.data/documents',
36
+ // 'unstorage' (built-in) or 'custom' (bring your own provider)
37
+ provider: 'unstorage',
38
+ },
39
+ })
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ### Basic — Upload and retrieve files
45
+
46
+ ```ts
47
+ // server/api/files/[groupId].post.ts
48
+ export default defineEventHandler(async (event) => {
49
+ const groupId = getRouterParam(event, 'groupId')!
50
+ const body = await readMultipartFormData(event)
51
+ const file = body![0]!
52
+
53
+ const storage = useFileStorage()
54
+
55
+ const id = await storage.upload(groupId, file.data, {
56
+ meta: {
57
+ name: file.filename || 'unnamed',
58
+ mime: file.type || 'application/octet-stream',
59
+ type: 'document',
60
+ version: 1,
61
+ },
62
+ })
63
+
64
+ return { id, groupId }
65
+ })
66
+ ```
67
+
68
+ ```ts
69
+ // server/api/files/[groupId].get.ts
70
+ export default defineEventHandler(async (event) => {
71
+ const groupId = getRouterParam(event, 'groupId')!
72
+ const storage = useFileStorage()
73
+
74
+ return await storage.list(groupId)
75
+ })
76
+ ```
77
+
78
+ ### `useFileStorage()` API
79
+
80
+ | Method | Description |
81
+ |---|---|
82
+ | `upload(groupId, data, options?)` | Store a file, returns its ID |
83
+ | `list(groupId)` | List all files in a group |
84
+ | `get(groupId, id)` | Get a file with data and metadata |
85
+ | `getData(groupId, id)` | Get raw binary data only |
86
+ | `getMeta(id)` | Get metadata only |
87
+ | `updateMeta(id, meta)` | Deep-merge metadata update |
88
+ | `remove(groupId, id)` | Delete a file |
89
+ | `clear(groupId)` | Delete all files in a group |
90
+ | `has(groupId, id)` | Check if a file exists |
91
+ | `findByMeta(key, value, groupId?)` | Find a file by metadata field |
92
+ | `checkDuplicate(groupId, key, value)` | Check if a duplicate exists |
93
+ | `getLatestVersions(groupId)` | Get latest version of each file by name |
94
+ | `getNextVersionNumber(files, name)` | Calculate next version number |
95
+ | `external?.sync(groupId, id)` | Sync with external system (if provider supports it) |
96
+ | `external?.push(groupId, id, data, meta)` | Push to external system |
97
+ | `external?.pull(groupId, ref)` | Pull from external system |
98
+
99
+ ## Custom Provider
100
+
101
+ For advanced use cases (database-backed metadata, S3 storage, external file sync), implement the `FileStorageProvider` interface and register it in a Nitro plugin:
102
+
103
+ ```ts
104
+ // nuxt.config.ts
105
+ export default defineNuxtConfig({
106
+ modules: ['nuxt-filer'],
107
+ filer: {
108
+ provider: 'custom',
109
+ },
110
+ })
111
+ ```
112
+
113
+ ```ts
114
+ // server/plugins/file-provider.ts
115
+ export default defineNitroPlugin(() => {
116
+ setFileStorageProvider({
117
+ async create(groupId, data, meta) {
118
+ // Store binary data (e.g. S3, unstorage)
119
+ // Store metadata (e.g. Prisma, Drizzle)
120
+ return { id: '...' }
121
+ },
122
+ async get(groupId, id) { /* ... */ },
123
+ async getData(groupId, id) { /* ... */ },
124
+ async getMeta(id) { /* ... */ },
125
+ async list(groupId) { /* ... */ },
126
+ async update(id, meta) { /* ... */ },
127
+ async remove(groupId, id) { /* ... */ },
128
+ async clear(groupId) { /* ... */ },
129
+ async has(groupId, id) { /* ... */ },
130
+ async findByMeta(filter) { /* ... */ },
131
+
132
+ // Optional: external file sync
133
+ external: {
134
+ async sync(groupId, id) { /* ... */ },
135
+ async push(groupId, id, data, meta) { /* ... */ },
136
+ async pull(groupId, externalRef) { /* ... */ },
137
+ },
138
+ })
139
+ })
140
+ ```
141
+
142
+ ### Provider interface
143
+
144
+ ```ts
145
+ interface FileStorageProvider {
146
+ create(groupId: string, data: Buffer | Uint8Array, meta?: FileMeta): Promise<{ id: string }>
147
+ get(groupId: string, id: string): Promise<StoredFile | null>
148
+ getData(groupId: string, id: string): Promise<Buffer | null>
149
+ getMeta(id: string): Promise<FileMeta | null>
150
+ list(groupId: string): Promise<StoredFile[]>
151
+ update(id: string, meta: Partial<FileMeta>): Promise<void>
152
+ remove(groupId: string, id: string): Promise<void>
153
+ clear(groupId: string): Promise<void>
154
+ has(groupId: string, id: string): Promise<boolean>
155
+ findByMeta(filter: { key: string; value: unknown; groupId?: string }): Promise<StoredFile | null>
156
+ external?: FileStorageExternalProvider
157
+ }
158
+ ```
159
+
160
+ ### Types
161
+
162
+ ```ts
163
+ interface FileMeta {
164
+ name: string
165
+ mime: string
166
+ type: string
167
+ version: number
168
+ username?: string
169
+ comment?: string
170
+ [key: string]: unknown // extend with your own fields
171
+ }
172
+
173
+ interface StoredFile {
174
+ id: string
175
+ groupId: string
176
+ data?: Buffer
177
+ meta: FileMeta
178
+ external?: ExternalRef
179
+ createdAt?: Date
180
+ updatedAt?: Date
181
+ }
182
+
183
+ interface ExternalRef {
184
+ source: string // e.g. 'jira', 'sharepoint'
185
+ externalId: string
186
+ externalUrl?: string
187
+ cachedAt?: Date
188
+ }
189
+ ```
190
+
191
+ ## Contribution
192
+
193
+ <details>
194
+ <summary>Local development</summary>
195
+
196
+ ```bash
197
+ # Install dependencies
198
+ pnpm install
199
+
200
+ # Generate type stubs
201
+ pnpm run dev:prepare
202
+
203
+ # Develop with the playground
204
+ pnpm run dev
205
+
206
+ # Run ESLint
207
+ pnpm run lint
208
+
209
+ # Run Vitest
210
+ pnpm run test
211
+
212
+ # Release new version
213
+ pnpm run release
214
+ ```
215
+
216
+ </details>
217
+
218
+ ## License
219
+
220
+ [MIT](./LICENSE)
221
+
222
+ <!-- Badges -->
223
+
224
+ [npm-version-src]: https://img.shields.io/npm/v/nuxt-filer/latest.svg?style=flat&colorA=020420&colorB=00DC82
225
+ [npm-version-href]: https://npmjs.com/package/nuxt-filer
226
+ [npm-downloads-src]: https://img.shields.io/npm/dm/nuxt-filer.svg?style=flat&colorA=020420&colorB=00DC82
227
+ [npm-downloads-href]: https://npm.chart.dev/nuxt-filer
228
+ [license-src]: https://img.shields.io/npm/l/nuxt-filer.svg?style=flat&colorA=020420&colorB=00DC82
229
+ [license-href]: https://npmjs.com/package/nuxt-filer
230
+ [nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt.js
231
+ [nuxt-href]: https://nuxt.com
@@ -0,0 +1,14 @@
1
+ import * as _nuxt_schema from '@nuxt/schema';
2
+
3
+ interface ModuleOptions {
4
+ /** Nitro storage mount name for binary file data. Default: 'documents' */
5
+ storageName?: string;
6
+ /** Base path for the default fs-lite storage driver. Default: '.data/documents' */
7
+ storagePath?: string;
8
+ /** Provider mode. 'unstorage' uses the built-in unstorage provider. 'custom' expects you to call setFileStorageProvider() in a Nitro plugin. Default: 'unstorage' */
9
+ provider?: 'unstorage' | 'custom';
10
+ }
11
+ declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
12
+
13
+ export { _default as default };
14
+ export type { ModuleOptions };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "nuxt-filer",
3
+ "configKey": "filer",
4
+ "version": "0.0.2",
5
+ "builder": {
6
+ "@nuxt/module-builder": "1.0.2",
7
+ "unbuild": "3.6.1"
8
+ }
9
+ }
@@ -0,0 +1,64 @@
1
+ import { defineNuxtModule, createResolver, addServerImports, addImports } from '@nuxt/kit';
2
+ import { consola } from 'consola';
3
+
4
+ const module = defineNuxtModule({
5
+ meta: {
6
+ name: "nuxt-filer",
7
+ configKey: "filer"
8
+ },
9
+ defaults: {
10
+ storageName: "documents",
11
+ storagePath: ".data/documents",
12
+ provider: "unstorage"
13
+ },
14
+ setup(options, nuxt) {
15
+ const resolver = createResolver(import.meta.url);
16
+ addServerImports([
17
+ {
18
+ name: "useFileStorage",
19
+ from: resolver.resolve("./runtime/server/utils/storage")
20
+ }
21
+ ]);
22
+ const providerSpecifier = resolver.resolve("./runtime/server/provider");
23
+ addServerImports([
24
+ { name: "setFileStorageProvider", from: providerSpecifier },
25
+ { name: "useFileStorageProvider", from: providerSpecifier }
26
+ ]);
27
+ addServerImports([
28
+ {
29
+ name: "createUnstorageProvider",
30
+ from: resolver.resolve("./runtime/server/providers/unstorage")
31
+ }
32
+ ]);
33
+ const typesSpecifier = "nuxt-filer/runtime/types";
34
+ addImports([
35
+ { name: "FileMeta", from: typesSpecifier, type: true },
36
+ { name: "StoredFile", from: typesSpecifier, type: true },
37
+ { name: "ExternalRef", from: typesSpecifier, type: true },
38
+ { name: "FileStorageProvider", from: typesSpecifier, type: true },
39
+ {
40
+ name: "FileStorageExternalProvider",
41
+ from: typesSpecifier,
42
+ type: true
43
+ }
44
+ ]);
45
+ nuxt.hook("nitro:config", (nitroConfig) => {
46
+ nitroConfig.virtual = nitroConfig.virtual || {};
47
+ nitroConfig.virtual["#nuxt-filer-options"] = `export const storageName = ${JSON.stringify(options.storageName)}`;
48
+ if (options.provider === "unstorage") {
49
+ nitroConfig.storage = nitroConfig.storage || {};
50
+ nitroConfig.storage[options.storageName] = {
51
+ driver: "fsLite",
52
+ base: options.storagePath
53
+ };
54
+ nitroConfig.plugins = nitroConfig.plugins || [];
55
+ nitroConfig.plugins.push(
56
+ resolver.resolve("./runtime/server/plugins/default-provider")
57
+ );
58
+ }
59
+ });
60
+ consola.success("nuxt-filer ready");
61
+ }
62
+ });
63
+
64
+ export { module as default };
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,6 @@
1
+ import { storageName } from "#nuxt-filer-options";
2
+ import { createUnstorageProvider } from "../providers/unstorage.js";
3
+ import { setFileStorageProvider } from "../provider.js";
4
+ export default defineNitroPlugin(() => {
5
+ setFileStorageProvider(createUnstorageProvider(storageName));
6
+ });
@@ -0,0 +1,3 @@
1
+ import type { FileStorageProvider } from '../../runtime/types.js';
2
+ export declare function setFileStorageProvider(provider: FileStorageProvider): void;
3
+ export declare function useFileStorageProvider(): FileStorageProvider;
@@ -0,0 +1,12 @@
1
+ let _provider = null;
2
+ export function setFileStorageProvider(provider) {
3
+ _provider = provider;
4
+ }
5
+ export function useFileStorageProvider() {
6
+ if (!_provider) {
7
+ throw new Error(
8
+ 'No file storage provider configured. Call setFileStorageProvider() in a Nitro plugin or set provider option to "unstorage" in module config.'
9
+ );
10
+ }
11
+ return _provider;
12
+ }
@@ -0,0 +1,7 @@
1
+ import type { FileStorageProvider } from '../../../runtime/types.js';
2
+ /**
3
+ * Built-in provider that uses Nitro's useStorage() for both binary data and metadata.
4
+ * Metadata is stored as JSON sidecar files alongside the binary data.
5
+ * No database dependency required.
6
+ */
7
+ export declare function createUnstorageProvider(storageName: string): FileStorageProvider;
@@ -0,0 +1,128 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export function createUnstorageProvider(storageName) {
3
+ function getStorage() {
4
+ return useStorage(storageName);
5
+ }
6
+ function dataKey(groupId, id) {
7
+ return `${groupId}:data:${id}`;
8
+ }
9
+ function metaKey(groupId, id) {
10
+ return `${groupId}:meta:${id}`;
11
+ }
12
+ return {
13
+ async create(groupId, data, meta) {
14
+ const storage = getStorage();
15
+ const id = randomUUID();
16
+ await storage.setItemRaw(dataKey(groupId, id), data);
17
+ if (meta) {
18
+ await storage.setItem(metaKey(groupId, id), {
19
+ ...meta,
20
+ _createdAt: (/* @__PURE__ */ new Date()).toISOString(),
21
+ _updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22
+ });
23
+ }
24
+ return { id };
25
+ },
26
+ async get(groupId, id) {
27
+ const storage = getStorage();
28
+ const data = await storage.getItemRaw(dataKey(groupId, id));
29
+ const metaData = await storage.getItem(metaKey(groupId, id));
30
+ if (!data && !metaData) return null;
31
+ return {
32
+ id,
33
+ groupId,
34
+ data: data ?? void 0,
35
+ meta: stripInternal(metaData),
36
+ createdAt: metaData?._createdAt ? new Date(metaData._createdAt) : void 0,
37
+ updatedAt: metaData?._updatedAt ? new Date(metaData._updatedAt) : void 0
38
+ };
39
+ },
40
+ async getData(groupId, id) {
41
+ return await getStorage().getItemRaw(dataKey(groupId, id));
42
+ },
43
+ async getMeta(id) {
44
+ const storage = getStorage();
45
+ const keys = await storage.getKeys();
46
+ const key = keys.find((k) => k.endsWith(`:meta:${id}`));
47
+ if (!key) return null;
48
+ const metaData = await storage.getItem(key);
49
+ return metaData ? stripInternal(metaData) : null;
50
+ },
51
+ async list(groupId) {
52
+ const storage = getStorage();
53
+ const keys = await storage.getKeys(groupId);
54
+ const metaKeys = keys.filter((k) => k.includes(":meta:"));
55
+ const files = [];
56
+ for (const key of metaKeys) {
57
+ const parts = key.split(":");
58
+ const id = parts[parts.length - 1];
59
+ const metaData = await storage.getItem(key);
60
+ if (!metaData) continue;
61
+ files.push({
62
+ id,
63
+ groupId,
64
+ meta: stripInternal(metaData),
65
+ createdAt: metaData._createdAt ? new Date(metaData._createdAt) : void 0,
66
+ updatedAt: metaData._updatedAt ? new Date(metaData._updatedAt) : void 0
67
+ });
68
+ }
69
+ return files;
70
+ },
71
+ async update(id, meta) {
72
+ const storage = getStorage();
73
+ const keys = await storage.getKeys();
74
+ const key = keys.find((k) => k.endsWith(`:meta:${id}`));
75
+ if (!key) throw new Error(`File metadata not found: ${id}`);
76
+ const existing = await storage.getItem(key);
77
+ await storage.setItem(key, {
78
+ ...existing,
79
+ ...meta,
80
+ _updatedAt: (/* @__PURE__ */ new Date()).toISOString()
81
+ });
82
+ },
83
+ async remove(groupId, id) {
84
+ const storage = getStorage();
85
+ await storage.removeItem(dataKey(groupId, id));
86
+ await storage.removeItem(metaKey(groupId, id));
87
+ },
88
+ async clear(groupId) {
89
+ const storage = getStorage();
90
+ const keys = await storage.getKeys(groupId);
91
+ for (const key of keys) {
92
+ await storage.removeItem(key);
93
+ }
94
+ },
95
+ async has(groupId, id) {
96
+ return await getStorage().hasItem(dataKey(groupId, id));
97
+ },
98
+ async findByMeta(filter) {
99
+ const storage = getStorage();
100
+ const prefix = filter.groupId ?? "";
101
+ const keys = await storage.getKeys(prefix);
102
+ const metaKeys = keys.filter((k) => k.includes(":meta:"));
103
+ for (const key of metaKeys) {
104
+ const metaData = await storage.getItem(key);
105
+ if (!metaData) continue;
106
+ if (metaData[filter.key] === filter.value) {
107
+ const parts = key.split(":");
108
+ const id = parts[parts.length - 1];
109
+ const metaIdx = parts.indexOf("meta");
110
+ const groupId = parts.slice(0, metaIdx).join(":");
111
+ return {
112
+ id,
113
+ groupId,
114
+ meta: stripInternal(metaData),
115
+ createdAt: metaData._createdAt ? new Date(metaData._createdAt) : void 0,
116
+ updatedAt: metaData._updatedAt ? new Date(metaData._updatedAt) : void 0
117
+ };
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ };
123
+ }
124
+ function stripInternal(meta) {
125
+ if (!meta) return { name: "", mime: "", type: "", version: 0 };
126
+ const { _createdAt, _updatedAt, ...rest } = meta;
127
+ return rest;
128
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "../../../.nuxt/tsconfig.server.json",
3
+ }
@@ -0,0 +1,24 @@
1
+ import type { FileMeta, StoredFile, ExternalRef, FileStorageProvider } from '../../../runtime/types.js';
2
+ export type { FileMeta, StoredFile, ExternalRef, FileStorageProvider };
3
+ export declare const useFileStorage: () => {
4
+ upload: (groupId: string, data: Buffer | Uint8Array, options?: {
5
+ meta?: FileMeta;
6
+ }) => Promise<string>;
7
+ list: (groupId: string) => Promise<StoredFile[]>;
8
+ get: (groupId: string, id: string) => Promise<StoredFile | null>;
9
+ getData: (groupId: string, id: string) => Promise<Buffer | null>;
10
+ getMeta: (id: string) => Promise<FileMeta | null>;
11
+ updateMeta: (id: string, meta: Partial<FileMeta>) => Promise<void>;
12
+ remove: (groupId: string, id: string) => Promise<void>;
13
+ clear: (groupId: string) => Promise<void>;
14
+ has: (groupId: string, id: string) => Promise<boolean>;
15
+ findByMeta: (key: string, value: unknown, groupId?: string) => Promise<StoredFile | null>;
16
+ checkDuplicate: (groupId: string, key: string, value: unknown) => Promise<boolean>;
17
+ getLatestVersions: (groupId: string) => Promise<StoredFile[]>;
18
+ getNextVersionNumber: (files: StoredFile[], name: string) => number;
19
+ external: {
20
+ sync: (groupId: string, id: string) => Promise<void>;
21
+ push: (groupId: string, id: string, data: Buffer, meta: FileMeta) => Promise<void>;
22
+ pull: (groupId: string, externalRef: ExternalRef) => Promise<StoredFile>;
23
+ } | undefined;
24
+ };
@@ -0,0 +1,82 @@
1
+ import { defu } from "defu";
2
+ import { useFileStorageProvider } from "../provider.js";
3
+ export const useFileStorage = () => {
4
+ const provider = useFileStorageProvider();
5
+ async function upload(groupId, data, options = {}) {
6
+ const { id } = await provider.create(groupId, data, options.meta);
7
+ return id;
8
+ }
9
+ async function list(groupId) {
10
+ return await provider.list(groupId);
11
+ }
12
+ async function get(groupId, id) {
13
+ return await provider.get(groupId, id);
14
+ }
15
+ async function getData(groupId, id) {
16
+ return await provider.getData(groupId, id);
17
+ }
18
+ async function getMeta(id) {
19
+ return await provider.getMeta(id);
20
+ }
21
+ async function updateMeta(id, meta) {
22
+ const existing = await provider.getMeta(id);
23
+ const merged = defu(meta, existing);
24
+ await provider.update(id, merged);
25
+ }
26
+ async function remove(groupId, id) {
27
+ await provider.remove(groupId, id);
28
+ }
29
+ async function clear(groupId) {
30
+ await provider.clear(groupId);
31
+ }
32
+ async function has(groupId, id) {
33
+ return await provider.has(groupId, id);
34
+ }
35
+ async function findByMeta(key, value, groupId) {
36
+ return await provider.findByMeta({ key, value, groupId });
37
+ }
38
+ async function checkDuplicate(groupId, key, value) {
39
+ const file = await provider.findByMeta({ key, value, groupId });
40
+ return !!file;
41
+ }
42
+ async function getLatestVersions(groupId) {
43
+ const files = await list(groupId);
44
+ const fileMap = /* @__PURE__ */ new Map();
45
+ for (const file of files) {
46
+ const existing = fileMap.get(file.meta.name);
47
+ const version = file.meta.version ?? 0;
48
+ const existingVersion = existing?.meta.version ?? 0;
49
+ if (!existing || version > existingVersion) {
50
+ fileMap.set(file.meta.name, file);
51
+ }
52
+ }
53
+ return Array.from(fileMap.values());
54
+ }
55
+ function getNextVersionNumber(files, name) {
56
+ const matching = files.filter((f) => f.meta.name === name).sort((a, b) => a.meta.version - b.meta.version);
57
+ const newest = matching[matching.length - 1];
58
+ if (!newest) return 1;
59
+ return newest.meta.version + 1;
60
+ }
61
+ const external = provider.external ? {
62
+ sync: provider.external.sync.bind(provider.external),
63
+ push: provider.external.push.bind(provider.external),
64
+ pull: provider.external.pull.bind(provider.external)
65
+ } : void 0;
66
+ return {
67
+ upload,
68
+ list,
69
+ get,
70
+ getData,
71
+ getMeta,
72
+ updateMeta,
73
+ remove,
74
+ clear,
75
+ has,
76
+ findByMeta,
77
+ checkDuplicate,
78
+ getLatestVersions,
79
+ getNextVersionNumber,
80
+ external
81
+ };
82
+ };
@@ -0,0 +1,56 @@
1
+ export interface FileMeta {
2
+ name: string;
3
+ mime: string;
4
+ type: string;
5
+ version: number;
6
+ username?: string;
7
+ comment?: string;
8
+ [key: string]: unknown;
9
+ }
10
+ export interface ExternalRef {
11
+ /** External system identifier, e.g. 'jira', 'sharepoint' */
12
+ source: string;
13
+ /** ID of the file in the external system */
14
+ externalId: string;
15
+ /** URL to the file in the external system */
16
+ externalUrl?: string;
17
+ /** When metadata/thumbnail was last synced */
18
+ cachedAt?: Date;
19
+ }
20
+ export interface StoredFile {
21
+ id: string;
22
+ groupId: string;
23
+ data?: Buffer;
24
+ meta: FileMeta;
25
+ external?: ExternalRef;
26
+ createdAt?: Date;
27
+ updatedAt?: Date;
28
+ }
29
+ export interface FileStorageExternalProvider {
30
+ /** Sync local cache with the external system (pull latest metadata/thumbnail) */
31
+ sync(groupId: string, id: string): Promise<void>;
32
+ /** Push local file data and metadata to the external system */
33
+ push(groupId: string, id: string, data: Buffer, meta: FileMeta): Promise<void>;
34
+ /** Pull a file from the external system into local storage */
35
+ pull(groupId: string, externalRef: ExternalRef): Promise<StoredFile>;
36
+ }
37
+ export interface FileStorageProvider {
38
+ create(groupId: string, data: Buffer | Uint8Array, meta?: FileMeta): Promise<{
39
+ id: string;
40
+ }>;
41
+ get(groupId: string, id: string): Promise<StoredFile | null>;
42
+ getData(groupId: string, id: string): Promise<Buffer | null>;
43
+ getMeta(id: string): Promise<FileMeta | null>;
44
+ list(groupId: string): Promise<StoredFile[]>;
45
+ update(id: string, meta: Partial<FileMeta>): Promise<void>;
46
+ remove(groupId: string, id: string): Promise<void>;
47
+ clear(groupId: string): Promise<void>;
48
+ has(groupId: string, id: string): Promise<boolean>;
49
+ findByMeta(filter: {
50
+ key: string;
51
+ value: unknown;
52
+ groupId?: string;
53
+ }): Promise<StoredFile | null>;
54
+ /** Optional external file sync support */
55
+ external?: FileStorageExternalProvider;
56
+ }
File without changes
@@ -0,0 +1,3 @@
1
+ export { default } from './module.mjs'
2
+
3
+ export { type ModuleOptions } from './module.mjs'
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "nuxt-filer",
3
+ "version": "0.0.2",
4
+ "description": "File storage module for Nuxt",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Niki2k1/nuxt-filer.git"
8
+ },
9
+ "homepage": "https://github.com/Niki2k1/nuxt-filer#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/Niki2k1/nuxt-filer/issues"
12
+ },
13
+ "license": "MIT",
14
+ "author": "Niki2k1",
15
+ "type": "module",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/types.d.mts",
19
+ "import": "./dist/module.mjs"
20
+ },
21
+ "./runtime/types": {
22
+ "types": "./src/runtime/types.ts",
23
+ "import": "./src/runtime/types.ts",
24
+ "default": "./src/runtime/types.ts"
25
+ }
26
+ },
27
+ "main": "./dist/module.mjs",
28
+ "typesVersions": {
29
+ "*": {
30
+ ".": [
31
+ "./dist/types.d.mts"
32
+ ]
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src/runtime"
38
+ ],
39
+ "scripts": {
40
+ "prepack": "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
+ },
50
+ "dependencies": {
51
+ "@nuxt/kit": "^4.4.2",
52
+ "consola": "^3.4.2",
53
+ "defu": "^6.1.4"
54
+ },
55
+ "devDependencies": {
56
+ "@nuxt/devtools": "^3.2.4",
57
+ "@nuxt/eslint-config": "^1.15.2",
58
+ "@nuxt/module-builder": "^1.0.2",
59
+ "@nuxt/schema": "^4.4.2",
60
+ "@nuxt/test-utils": "^4.0.0",
61
+ "@types/node": "latest",
62
+ "changelogen": "^0.6.2",
63
+ "eslint": "^10.1.0",
64
+ "nuxt": "^4.4.2",
65
+ "typescript": "~6.0.2",
66
+ "vitest": "^4.1.2",
67
+ "vue-tsc": "^3.2.6"
68
+ },
69
+ "packageManager": "pnpm@10.33.0",
70
+ "pnpm": {
71
+ "onlyBuiltDependencies": [
72
+ "esbuild"
73
+ ]
74
+ }
75
+ }
@@ -0,0 +1,8 @@
1
+ // @ts-expect-error virtual module
2
+ import { storageName } from '#nuxt-filer-options';
3
+ import { createUnstorageProvider } from '../providers/unstorage';
4
+ import { setFileStorageProvider } from '../provider';
5
+
6
+ export default defineNitroPlugin(() => {
7
+ setFileStorageProvider(createUnstorageProvider(storageName));
8
+ });
@@ -0,0 +1,16 @@
1
+ import type { FileStorageProvider } from '../../runtime/types';
2
+
3
+ let _provider: FileStorageProvider | null = null;
4
+
5
+ export function setFileStorageProvider(provider: FileStorageProvider) {
6
+ _provider = provider;
7
+ }
8
+
9
+ export function useFileStorageProvider(): FileStorageProvider {
10
+ if (!_provider) {
11
+ throw new Error(
12
+ 'No file storage provider configured. Call setFileStorageProvider() in a Nitro plugin or set provider option to "unstorage" in module config.'
13
+ );
14
+ }
15
+ return _provider;
16
+ }
@@ -0,0 +1,194 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type {
3
+ FileStorageProvider,
4
+ FileMeta,
5
+ StoredFile,
6
+ } from '../../../runtime/types';
7
+
8
+ /**
9
+ * Built-in provider that uses Nitro's useStorage() for both binary data and metadata.
10
+ * Metadata is stored as JSON sidecar files alongside the binary data.
11
+ * No database dependency required.
12
+ */
13
+ export function createUnstorageProvider(
14
+ storageName: string
15
+ ): FileStorageProvider {
16
+ function getStorage() {
17
+ return useStorage(storageName);
18
+ }
19
+
20
+ function dataKey(groupId: string, id: string) {
21
+ return `${groupId}:data:${id}`;
22
+ }
23
+
24
+ function metaKey(groupId: string, id: string) {
25
+ return `${groupId}:meta:${id}`;
26
+ }
27
+
28
+ return {
29
+ async create(groupId, data, meta) {
30
+ const storage = getStorage();
31
+ const id = randomUUID();
32
+
33
+ await storage.setItemRaw(dataKey(groupId, id), data);
34
+
35
+ if (meta) {
36
+ await storage.setItem(metaKey(groupId, id), {
37
+ ...meta,
38
+ _createdAt: new Date().toISOString(),
39
+ _updatedAt: new Date().toISOString(),
40
+ });
41
+ }
42
+
43
+ return { id };
44
+ },
45
+
46
+ async get(groupId, id) {
47
+ const storage = getStorage();
48
+ const data = await storage.getItemRaw<Buffer>(dataKey(groupId, id));
49
+ const metaData = await storage.getItem<
50
+ FileMeta & { _createdAt?: string; _updatedAt?: string }
51
+ >(metaKey(groupId, id));
52
+
53
+ if (!data && !metaData) return null;
54
+
55
+ return {
56
+ id,
57
+ groupId,
58
+ data: data ?? undefined,
59
+ meta: stripInternal(metaData),
60
+ createdAt: metaData?._createdAt
61
+ ? new Date(metaData._createdAt)
62
+ : undefined,
63
+ updatedAt: metaData?._updatedAt
64
+ ? new Date(metaData._updatedAt)
65
+ : undefined,
66
+ };
67
+ },
68
+
69
+ async getData(groupId, id) {
70
+ return await getStorage().getItemRaw<Buffer>(dataKey(groupId, id));
71
+ },
72
+
73
+ async getMeta(id) {
74
+ const storage = getStorage();
75
+ const keys = await storage.getKeys();
76
+ const key = keys.find((k) => k.endsWith(`:meta:${id}`));
77
+ if (!key) return null;
78
+
79
+ const metaData = await storage.getItem<FileMeta>(key);
80
+ return metaData ? stripInternal(metaData) : null;
81
+ },
82
+
83
+ async list(groupId) {
84
+ const storage = getStorage();
85
+ const keys = await storage.getKeys(groupId);
86
+
87
+ // Get unique file IDs by filtering meta keys (format: groupId:meta:id)
88
+ const metaKeys = keys.filter((k) => k.includes(':meta:'));
89
+ const files: StoredFile[] = [];
90
+
91
+ for (const key of metaKeys) {
92
+ const parts = key.split(':');
93
+ const id = parts[parts.length - 1]!;
94
+
95
+ const metaData = await storage.getItem<
96
+ FileMeta & { _createdAt?: string; _updatedAt?: string }
97
+ >(key);
98
+ if (!metaData) continue;
99
+
100
+ files.push({
101
+ id,
102
+ groupId,
103
+ meta: stripInternal(metaData),
104
+ createdAt: metaData._createdAt
105
+ ? new Date(metaData._createdAt)
106
+ : undefined,
107
+ updatedAt: metaData._updatedAt
108
+ ? new Date(metaData._updatedAt)
109
+ : undefined,
110
+ });
111
+ }
112
+
113
+ return files;
114
+ },
115
+
116
+ async update(id, meta) {
117
+ const storage = getStorage();
118
+ const keys = await storage.getKeys();
119
+ const key = keys.find((k) => k.endsWith(`:meta:${id}`));
120
+ if (!key) throw new Error(`File metadata not found: ${id}`);
121
+
122
+ const existing = await storage.getItem<
123
+ FileMeta & { _createdAt?: string; _updatedAt?: string }
124
+ >(key);
125
+
126
+ await storage.setItem(key, {
127
+ ...existing,
128
+ ...meta,
129
+ _updatedAt: new Date().toISOString(),
130
+ });
131
+ },
132
+
133
+ async remove(groupId, id) {
134
+ const storage = getStorage();
135
+ await storage.removeItem(dataKey(groupId, id));
136
+ await storage.removeItem(metaKey(groupId, id));
137
+ },
138
+
139
+ async clear(groupId) {
140
+ const storage = getStorage();
141
+ const keys = await storage.getKeys(groupId);
142
+ for (const key of keys) {
143
+ await storage.removeItem(key);
144
+ }
145
+ },
146
+
147
+ async has(groupId, id) {
148
+ return await getStorage().hasItem(dataKey(groupId, id));
149
+ },
150
+
151
+ async findByMeta(filter) {
152
+ const storage = getStorage();
153
+ const prefix = filter.groupId ?? '';
154
+ const keys = await storage.getKeys(prefix);
155
+ const metaKeys = keys.filter((k) => k.includes(':meta:'));
156
+
157
+ for (const key of metaKeys) {
158
+ const metaData = await storage.getItem<
159
+ FileMeta & { _createdAt?: string; _updatedAt?: string }
160
+ >(key);
161
+ if (!metaData) continue;
162
+
163
+ if (metaData[filter.key] === filter.value) {
164
+ const parts = key.split(':');
165
+ const id = parts[parts.length - 1]!;
166
+ const metaIdx = parts.indexOf('meta');
167
+ const groupId = parts.slice(0, metaIdx).join(':');
168
+
169
+ return {
170
+ id,
171
+ groupId,
172
+ meta: stripInternal(metaData),
173
+ createdAt: metaData._createdAt
174
+ ? new Date(metaData._createdAt)
175
+ : undefined,
176
+ updatedAt: metaData._updatedAt
177
+ ? new Date(metaData._updatedAt)
178
+ : undefined,
179
+ };
180
+ }
181
+ }
182
+
183
+ return null;
184
+ },
185
+ };
186
+ }
187
+
188
+ function stripInternal(
189
+ meta: (FileMeta & { _createdAt?: string; _updatedAt?: string }) | null
190
+ ): FileMeta {
191
+ if (!meta) return { name: '', mime: '', type: '', version: 0 };
192
+ const { _createdAt, _updatedAt, ...rest } = meta;
193
+ return rest as FileMeta;
194
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "../../../.nuxt/tsconfig.server.json",
3
+ }
@@ -0,0 +1,140 @@
1
+ import { defu } from 'defu';
2
+ import type {
3
+ FileMeta,
4
+ StoredFile,
5
+ ExternalRef,
6
+ FileStorageProvider,
7
+ } from '../../../runtime/types';
8
+ import { useFileStorageProvider } from '../provider';
9
+
10
+ export type { FileMeta, StoredFile, ExternalRef, FileStorageProvider };
11
+
12
+ export const useFileStorage = () => {
13
+ const provider = useFileStorageProvider();
14
+
15
+ async function upload(
16
+ groupId: string,
17
+ data: Buffer | Uint8Array,
18
+ options: { meta?: FileMeta } = {}
19
+ ): Promise<string> {
20
+ const { id } = await provider.create(groupId, data, options.meta);
21
+ return id;
22
+ }
23
+
24
+ async function list(groupId: string): Promise<StoredFile[]> {
25
+ return await provider.list(groupId);
26
+ }
27
+
28
+ async function get(
29
+ groupId: string,
30
+ id: string
31
+ ): Promise<StoredFile | null> {
32
+ return await provider.get(groupId, id);
33
+ }
34
+
35
+ async function getData(
36
+ groupId: string,
37
+ id: string
38
+ ): Promise<Buffer | null> {
39
+ return await provider.getData(groupId, id);
40
+ }
41
+
42
+ async function getMeta(id: string): Promise<FileMeta | null> {
43
+ return await provider.getMeta(id);
44
+ }
45
+
46
+ async function updateMeta(id: string, meta: Partial<FileMeta>) {
47
+ const existing = await provider.getMeta(id);
48
+ const merged = defu(meta, existing) as Partial<FileMeta>;
49
+ await provider.update(id, merged);
50
+ }
51
+
52
+ async function remove(groupId: string, id: string) {
53
+ await provider.remove(groupId, id);
54
+ }
55
+
56
+ async function clear(groupId: string) {
57
+ await provider.clear(groupId);
58
+ }
59
+
60
+ async function has(groupId: string, id: string): Promise<boolean> {
61
+ return await provider.has(groupId, id);
62
+ }
63
+
64
+ async function findByMeta(
65
+ key: string,
66
+ value: unknown,
67
+ groupId?: string
68
+ ): Promise<StoredFile | null> {
69
+ return await provider.findByMeta({ key, value, groupId });
70
+ }
71
+
72
+ async function checkDuplicate(
73
+ groupId: string,
74
+ key: string,
75
+ value: unknown
76
+ ): Promise<boolean> {
77
+ const file = await provider.findByMeta({ key, value, groupId });
78
+ return !!file;
79
+ }
80
+
81
+ /**
82
+ * Get the latest version of each file (by name) within a group.
83
+ */
84
+ async function getLatestVersions(groupId: string): Promise<StoredFile[]> {
85
+ const files = await list(groupId);
86
+ const fileMap = new Map<string, StoredFile>();
87
+
88
+ for (const file of files) {
89
+ const existing = fileMap.get(file.meta.name);
90
+ const version = file.meta.version ?? 0;
91
+ const existingVersion = existing?.meta.version ?? 0;
92
+
93
+ if (!existing || version > existingVersion) {
94
+ fileMap.set(file.meta.name, file);
95
+ }
96
+ }
97
+
98
+ return Array.from(fileMap.values());
99
+ }
100
+
101
+ /**
102
+ * Determine the next version number for a file name.
103
+ */
104
+ function getNextVersionNumber(files: StoredFile[], name: string): number {
105
+ const matching = files
106
+ .filter((f) => f.meta.name === name)
107
+ .sort((a, b) => a.meta.version - b.meta.version);
108
+
109
+ const newest = matching[matching.length - 1];
110
+ if (!newest) return 1;
111
+
112
+ return newest.meta.version + 1;
113
+ }
114
+
115
+ // External file operations (only available if provider supports it)
116
+ const external = provider.external
117
+ ? {
118
+ sync: provider.external.sync.bind(provider.external),
119
+ push: provider.external.push.bind(provider.external),
120
+ pull: provider.external.pull.bind(provider.external),
121
+ }
122
+ : undefined;
123
+
124
+ return {
125
+ upload,
126
+ list,
127
+ get,
128
+ getData,
129
+ getMeta,
130
+ updateMeta,
131
+ remove,
132
+ clear,
133
+ has,
134
+ findByMeta,
135
+ checkDuplicate,
136
+ getLatestVersions,
137
+ getNextVersionNumber,
138
+ external,
139
+ };
140
+ };
@@ -0,0 +1,68 @@
1
+ export interface FileMeta {
2
+ name: string;
3
+ mime: string;
4
+ type: string;
5
+ version: number;
6
+ username?: string;
7
+ comment?: string;
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ export interface ExternalRef {
12
+ /** External system identifier, e.g. 'jira', 'sharepoint' */
13
+ source: string;
14
+ /** ID of the file in the external system */
15
+ externalId: string;
16
+ /** URL to the file in the external system */
17
+ externalUrl?: string;
18
+ /** When metadata/thumbnail was last synced */
19
+ cachedAt?: Date;
20
+ }
21
+
22
+ export interface StoredFile {
23
+ id: string;
24
+ groupId: string;
25
+ data?: Buffer;
26
+ meta: FileMeta;
27
+ external?: ExternalRef;
28
+ createdAt?: Date;
29
+ updatedAt?: Date;
30
+ }
31
+
32
+ export interface FileStorageExternalProvider {
33
+ /** Sync local cache with the external system (pull latest metadata/thumbnail) */
34
+ sync(groupId: string, id: string): Promise<void>;
35
+ /** Push local file data and metadata to the external system */
36
+ push(
37
+ groupId: string,
38
+ id: string,
39
+ data: Buffer,
40
+ meta: FileMeta
41
+ ): Promise<void>;
42
+ /** Pull a file from the external system into local storage */
43
+ pull(groupId: string, externalRef: ExternalRef): Promise<StoredFile>;
44
+ }
45
+
46
+ export interface FileStorageProvider {
47
+ create(
48
+ groupId: string,
49
+ data: Buffer | Uint8Array,
50
+ meta?: FileMeta
51
+ ): Promise<{ id: string }>;
52
+ get(groupId: string, id: string): Promise<StoredFile | null>;
53
+ getData(groupId: string, id: string): Promise<Buffer | null>;
54
+ getMeta(id: string): Promise<FileMeta | null>;
55
+ list(groupId: string): Promise<StoredFile[]>;
56
+ update(id: string, meta: Partial<FileMeta>): Promise<void>;
57
+ remove(groupId: string, id: string): Promise<void>;
58
+ clear(groupId: string): Promise<void>;
59
+ has(groupId: string, id: string): Promise<boolean>;
60
+ findByMeta(filter: {
61
+ key: string;
62
+ value: unknown;
63
+ groupId?: string;
64
+ }): Promise<StoredFile | null>;
65
+
66
+ /** Optional external file sync support */
67
+ external?: FileStorageExternalProvider;
68
+ }