nuxt-filer 0.0.6 → 0.0.9

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
@@ -16,6 +16,7 @@ File storage module for Nuxt. Provides a server-side `useFileStorage()` composab
16
16
  - **Auto-imported** — `useFileStorage()`, types, and provider utilities are auto-imported in server context
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
+ - **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
19
20
 
20
21
  ## Quick Setup
21
22
 
@@ -76,11 +77,49 @@ export default defineEventHandler(async (event) => {
76
77
  })
77
78
  ```
78
79
 
80
+ ### Upload-time image processing
81
+
82
+ Pass a `transform` option to `upload()` to process an image **before it is stored** — useful for normalizing user uploads or rehosted remote images to a capped size and compact format. The processed bytes are what gets stored, and the file's `meta.mime` / `meta.width` / `meta.height` are updated to match the result.
83
+
84
+ ```ts
85
+ const id = await storage.upload(groupId, file.data, {
86
+ meta: { name: file.filename!, mime: file.type!, type: 'image', version: 1 },
87
+ transform: {
88
+ width: 128,
89
+ height: 128,
90
+ format: 'webp', // convert to webp
91
+ // fit: 'inside' (default), withoutEnlargement: true (default),
92
+ // quality, background, animated (default true)
93
+ },
94
+ })
95
+ ```
96
+
97
+ You can also call the util directly (e.g. for images fetched server-side):
98
+
99
+ ```ts
100
+ const res = await transformImage(buffer, { width: 64, format: 'webp' })
101
+ // res: { data: Buffer, mime: 'image/webp', format: 'webp', width: 64, height: 64 }
102
+ ```
103
+
104
+ **`transform` / `transformImage()` options**
105
+
106
+ | Option | Description |
107
+ |---|---|
108
+ | `width`, `height` | Target box in px (combined with `fit`) |
109
+ | `fit` | Resize fit mode (`inside` default, or `cover`/`contain`/`fill`/`outside`) |
110
+ | `withoutEnlargement` | Never scale up beyond the original (default `true`) |
111
+ | `format` | Output format: `webp` / `png` / `jpeg` / `avif` / `gif` (default: keep input) |
112
+ | `quality` | Output quality `1-100` for lossy formats |
113
+ | `animated` | Preserve all frames of animated inputs (default `true`; only retained when `format` is `webp`/`gif`) |
114
+ | `background` | Background used when flattening transparency |
115
+
116
+ > Image processing requires the optional [`sharp`](https://sharp.pixelplumbing.com/) peer dependency. Install it (`npm i sharp`) only if you use `transform` / `transformImage()` — calling them without `sharp` throws a clear error. Without a `transform`, `upload()` stores the raw bytes unchanged and needs no extra dependency.
117
+
79
118
  ### `useFileStorage()` API
80
119
 
81
120
  | Method | Description |
82
121
  |---|---|
83
- | `upload(groupId, data, options?)` | Store a file, returns its ID |
122
+ | `upload(groupId, data, options?)` | Store a file, returns its ID. `options.transform` runs the bytes through Sharp first (see above) |
84
123
  | `list(groupId)` | List all files in a group |
85
124
  | `get(groupId, id)` | Get a file with data and metadata |
86
125
  | `getData(groupId, id)` | Get raw binary data only |
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-filer",
3
3
  "configKey": "filer",
4
- "version": "0.0.6",
4
+ "version": "0.0.9",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -18,6 +18,10 @@ const module = defineNuxtModule({
18
18
  {
19
19
  name: "useFileStorage",
20
20
  from: resolver.resolve("./runtime/server/utils/storage")
21
+ },
22
+ {
23
+ name: "transformImage",
24
+ from: resolver.resolve("./runtime/server/utils/image")
21
25
  }
22
26
  ]);
23
27
  const providerSpecifier = resolver.resolve("./runtime/server/provider");
@@ -41,7 +45,10 @@ const module = defineNuxtModule({
41
45
  name: "FileStorageExternalProvider",
42
46
  from: typesSpecifier,
43
47
  type: true
44
- }
48
+ },
49
+ { name: "ImageFormat", from: typesSpecifier, type: true },
50
+ { name: "ImageTransformOptions", from: typesSpecifier, type: true },
51
+ { name: "ImageTransformResult", from: typesSpecifier, type: true }
45
52
  ]);
46
53
  const imageOpt = typeof options.image === "object" && options.image !== null ? options.image : {};
47
54
  const imageEnabled = options.image !== false && (imageOpt.enabled ?? true) !== false;
@@ -73,15 +80,16 @@ const module = defineNuxtModule({
73
80
  }
74
81
  nuxt.hook("nitro:config", (nitroConfig) => {
75
82
  nitroConfig.virtual = nitroConfig.virtual || {};
76
- nitroConfig.virtual["#nuxt-filer-options"] = `export const storageName = ${JSON.stringify(options.storageName)}`;
83
+ nitroConfig.virtual["#nuxt-filer-options"] = [
84
+ `export const storageName = ${JSON.stringify(options.storageName)};`,
85
+ `export const storagePath = ${JSON.stringify(options.storagePath)};`
86
+ ].join("\n");
77
87
  nitroConfig.virtual["#nuxt-filer-image"] = `export const ipxRoute = ${JSON.stringify(ipxRoute)}`;
78
88
  if (options.provider === "unstorage") {
79
- nitroConfig.storage = nitroConfig.storage || {};
80
- nitroConfig.storage[options.storageName] = {
81
- driver: "fsLite",
82
- base: options.storagePath
83
- };
84
89
  nitroConfig.plugins = nitroConfig.plugins || [];
90
+ nitroConfig.plugins.push(
91
+ resolver.resolve("./runtime/server/plugins/default-storage")
92
+ );
85
93
  nitroConfig.plugins.push(
86
94
  resolver.resolve("./runtime/server/plugins/default-provider")
87
95
  );
@@ -3,5 +3,10 @@ interface FilerProviderOptions {
3
3
  baseURL?: string;
4
4
  }
5
5
  export declare const getImage: ProviderGetImage;
6
- export declare const validateDomains = false;
6
+ declare const _default: () => {
7
+ getImage: ProviderGetImage;
8
+ validateDomains: boolean;
9
+ supportsAlias: boolean;
10
+ };
11
+ export default _default;
7
12
  export type { FilerProviderOptions };
@@ -1,17 +1,35 @@
1
+ import { joinURL } from "ufo";
1
2
  const operationsGenerator = (modifiers) => {
2
- const ops = [];
3
- for (const [key, value] of Object.entries(modifiers)) {
3
+ const out = [];
4
+ if (modifiers.width && modifiers.height) {
5
+ modifiers.resize = `${modifiers.width}x${modifiers.height}`;
6
+ delete modifiers.width;
7
+ delete modifiers.height;
8
+ }
9
+ const keyMap = {
10
+ format: "f",
11
+ width: "w",
12
+ height: "h",
13
+ resize: "s",
14
+ quality: "q",
15
+ background: "b",
16
+ position: "pos"
17
+ };
18
+ for (const [rawKey, value] of Object.entries(modifiers)) {
4
19
  if (value === void 0 || value === null || value === "" || value === false) continue;
5
- ops.push(`${key}_${value}`);
20
+ const key = keyMap[rawKey] ?? rawKey;
21
+ out.push(`${key}_${value}`);
6
22
  }
7
- return ops.length ? ops.join(",") : "_";
23
+ return out.length ? out.join(",") : "_";
8
24
  };
9
- export const getImage = (src, { modifiers = {}, baseURL } = {}) => {
10
- const root = (baseURL ?? "/_filer-ipx").replace(/\/+$/, "");
25
+ export const getImage = (src, { modifiers = {}, baseURL = "/_filer-ipx" } = {}) => {
11
26
  const ops = operationsGenerator(
12
27
  modifiers
13
28
  );
14
- const path = src.replace(/^\/+/, "");
15
- return { url: `${root}/${ops}/${path}` };
29
+ return { url: joinURL(baseURL, ops, src.replace(/^\/+/, "")) };
16
30
  };
17
- export const validateDomains = false;
31
+ export default () => ({
32
+ getImage,
33
+ validateDomains: false,
34
+ supportsAlias: false
35
+ });
@@ -0,0 +1,34 @@
1
+ export interface FsDriverOptions {
2
+ /** Filesystem path used as the base for all keys. Required. */
3
+ base?: string;
4
+ /** Treat the storage as read-only; writes become no-ops. */
5
+ readOnly?: boolean;
6
+ /** Suppress `clear()`. */
7
+ noClear?: boolean;
8
+ /** Optional ignore filter applied during `getKeys`. */
9
+ ignore?: (path: string) => boolean;
10
+ }
11
+ export default function fsDriver(opts?: FsDriverOptions): {
12
+ name: string;
13
+ options: FsDriverOptions;
14
+ flags: {
15
+ maxDepth: boolean;
16
+ };
17
+ hasItem(key: string): boolean;
18
+ getItem(key: string): Promise<string | null>;
19
+ getItemRaw(key: string): Promise<Buffer<ArrayBufferLike> | null>;
20
+ getMeta(key: string): Promise<{
21
+ atime: Date | undefined;
22
+ mtime: Date | undefined;
23
+ size: number | undefined;
24
+ birthtime: Date | undefined;
25
+ ctime: Date | undefined;
26
+ }>;
27
+ setItem(key: string, value: string): Promise<void>;
28
+ setItemRaw(key: string, value: Buffer | Uint8Array): Promise<void>;
29
+ removeItem(key: string): Promise<void>;
30
+ getKeys(_base?: string, topts?: {
31
+ maxDepth?: number;
32
+ }): Promise<string[]>;
33
+ clear(): Promise<void>;
34
+ };
@@ -0,0 +1,106 @@
1
+ import {
2
+ existsSync,
3
+ promises as fsp
4
+ } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ const PATH_TRAVERSE_RE = /\.\.:|\.\.$/;
7
+ const DRIVER_NAME = "nuxt-filer-fs";
8
+ function driverError(message) {
9
+ return new Error(`[nuxt-filer] [${DRIVER_NAME}] ${message}`);
10
+ }
11
+ function ignoreNotfound(err) {
12
+ if (err.code === "ENOENT" || err.code === "EISDIR") return null;
13
+ throw err;
14
+ }
15
+ async function readdirSafe(dir) {
16
+ try {
17
+ return await fsp.readdir(dir, { withFileTypes: true });
18
+ } catch (err) {
19
+ if (err.code === "ENOENT") return [];
20
+ throw err;
21
+ }
22
+ }
23
+ async function readdirRecursive(dir, ignore, maxDepth) {
24
+ if (ignore && ignore(dir)) return [];
25
+ const entries = await readdirSafe(dir);
26
+ const files = [];
27
+ await Promise.all(
28
+ entries.map(async (entry) => {
29
+ const entryPath = resolve(dir, entry.name);
30
+ if (entry.isDirectory()) {
31
+ if (maxDepth === void 0 || maxDepth > 0) {
32
+ const nested = await readdirRecursive(
33
+ entryPath,
34
+ ignore,
35
+ maxDepth === void 0 ? void 0 : maxDepth - 1
36
+ );
37
+ files.push(...nested.map((f) => entry.name + "/" + f));
38
+ }
39
+ } else if (!(ignore && ignore(entry.name))) {
40
+ files.push(entry.name);
41
+ }
42
+ })
43
+ );
44
+ return files;
45
+ }
46
+ export default function fsDriver(opts = {}) {
47
+ if (!opts.base) {
48
+ throw driverError("Missing required option `base`.");
49
+ }
50
+ const base = resolve(opts.base);
51
+ const r = (key) => {
52
+ if (PATH_TRAVERSE_RE.test(key)) {
53
+ throw driverError(
54
+ `Invalid key: ${JSON.stringify(key)}. It should not contain .. segments`
55
+ );
56
+ }
57
+ return join(base, key.replace(/:/g, "/"));
58
+ };
59
+ async function write(path, value) {
60
+ await fsp.mkdir(dirname(path), { recursive: true });
61
+ await fsp.writeFile(path, value);
62
+ }
63
+ return {
64
+ name: DRIVER_NAME,
65
+ options: opts,
66
+ flags: { maxDepth: true },
67
+ hasItem(key) {
68
+ return existsSync(r(key));
69
+ },
70
+ getItem(key) {
71
+ return fsp.readFile(r(key), "utf8").catch((err) => ignoreNotfound(err));
72
+ },
73
+ getItemRaw(key) {
74
+ return fsp.readFile(r(key)).catch((err) => ignoreNotfound(err));
75
+ },
76
+ async getMeta(key) {
77
+ const stat = await fsp.stat(r(key)).catch(() => ({}));
78
+ return {
79
+ atime: stat.atime,
80
+ mtime: stat.mtime,
81
+ size: stat.size,
82
+ birthtime: stat.birthtime,
83
+ ctime: stat.ctime
84
+ };
85
+ },
86
+ async setItem(key, value) {
87
+ if (opts.readOnly) return;
88
+ await write(r(key), value);
89
+ },
90
+ async setItemRaw(key, value) {
91
+ if (opts.readOnly) return;
92
+ await write(r(key), value);
93
+ },
94
+ async removeItem(key) {
95
+ if (opts.readOnly) return;
96
+ await fsp.unlink(r(key)).catch((err) => ignoreNotfound(err));
97
+ },
98
+ getKeys(_base, topts) {
99
+ return readdirRecursive(r("."), opts.ignore, topts?.maxDepth);
100
+ },
101
+ async clear() {
102
+ if (opts.readOnly || opts.noClear) return;
103
+ await fsp.rm(r("."), { recursive: true, force: true });
104
+ }
105
+ };
106
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Mount the default filesystem-backed storage for nuxt-filer. We mount
3
+ * via a Nitro plugin rather than `nitroConfig.storage` so that our
4
+ * custom fs driver is bundled with the plugin and there is no runtime
5
+ * module resolution against the package's `dist/`.
6
+ */
7
+ declare const _default: import("nitropack/types").NitroAppPlugin;
8
+ export default _default;
@@ -0,0 +1,7 @@
1
+ import { defineNitroPlugin, useStorage } from "nitropack/runtime";
2
+ import { storageName, storagePath } from "#nuxt-filer-options";
3
+ import fsDriver from "../drivers/fs.js";
4
+ export default defineNitroPlugin(() => {
5
+ const storage = useStorage();
6
+ storage.mount(storageName, fsDriver({ base: storagePath }));
7
+ });
@@ -0,0 +1,11 @@
1
+ import type { ImageFormat, ImageTransformOptions, ImageTransformResult } from '../../../runtime/types.js';
2
+ export type { ImageFormat, ImageTransformOptions, ImageTransformResult };
3
+ /**
4
+ * Process an image buffer with sharp: resize within a box, convert format,
5
+ * optimize, and (by default) preserve animation for multi-frame inputs. Used
6
+ * for upload-time normalization — see `useFileStorage().upload({ transform })`.
7
+ *
8
+ * Requires the optional `sharp` peer dependency; throws a clear error if it is
9
+ * not installed.
10
+ */
11
+ export declare function transformImage(data: Buffer | Uint8Array, options?: ImageTransformOptions): Promise<ImageTransformResult>;
@@ -0,0 +1,50 @@
1
+ const MIME_BY_FORMAT = {
2
+ webp: "image/webp",
3
+ png: "image/png",
4
+ jpeg: "image/jpeg",
5
+ jpg: "image/jpeg",
6
+ avif: "image/avif",
7
+ gif: "image/gif"
8
+ };
9
+ let sharpModule;
10
+ async function loadSharp() {
11
+ if (sharpModule) return sharpModule;
12
+ try {
13
+ sharpModule = (await import("sharp")).default;
14
+ } catch {
15
+ throw new Error(
16
+ "[nuxt-filer] Image transforms require the optional peer dependency `sharp`. Install it with `npm i sharp`."
17
+ );
18
+ }
19
+ return sharpModule;
20
+ }
21
+ export async function transformImage(data, options = {}) {
22
+ const sharp = await loadSharp();
23
+ const animated = options.animated ?? true;
24
+ let pipeline = sharp(data, { animated });
25
+ if (options.width != null || options.height != null) {
26
+ pipeline = pipeline.resize({
27
+ width: options.width,
28
+ height: options.height,
29
+ fit: options.fit ?? "inside",
30
+ withoutEnlargement: options.withoutEnlargement ?? true,
31
+ background: options.background
32
+ });
33
+ }
34
+ if (options.format) {
35
+ const formatOptions = options.quality != null ? { quality: options.quality } : {};
36
+ pipeline = pipeline.toFormat(
37
+ options.format,
38
+ formatOptions
39
+ );
40
+ }
41
+ const out = await pipeline.toBuffer({ resolveWithObject: true });
42
+ const format = out.info.format;
43
+ return {
44
+ data: out.data,
45
+ format,
46
+ mime: MIME_BY_FORMAT[format] ?? `image/${format}`,
47
+ width: out.info.width,
48
+ height: out.info.height
49
+ };
50
+ }
@@ -1,8 +1,9 @@
1
- import type { FileMeta, StoredFile, ExternalRef, FileStorageProvider } from '../../../runtime/types.js';
2
- export type { FileMeta, StoredFile, ExternalRef, FileStorageProvider };
1
+ import type { FileMeta, StoredFile, ExternalRef, FileStorageProvider, ImageTransformOptions, ImageTransformResult } from '../../../runtime/types.js';
2
+ export type { FileMeta, StoredFile, ExternalRef, FileStorageProvider, ImageTransformOptions, ImageTransformResult, };
3
3
  export declare const useFileStorage: () => {
4
4
  upload: (groupId: string, data: Buffer | Uint8Array, options?: {
5
5
  meta?: FileMeta;
6
+ transform?: ImageTransformOptions;
6
7
  }) => Promise<string>;
7
8
  list: (groupId: string) => Promise<StoredFile[]>;
8
9
  get: (groupId: string, id: string) => Promise<StoredFile | null>;
@@ -1,9 +1,20 @@
1
1
  import { defu } from "defu";
2
2
  import { useFileStorageProvider } from "../provider.js";
3
+ import { transformImage } from "./image.js";
3
4
  export const useFileStorage = () => {
4
5
  const provider = useFileStorageProvider();
5
6
  async function upload(groupId, data, options = {}) {
6
- const { id } = await provider.create(groupId, data, options.meta);
7
+ let payload = data;
8
+ if (options.transform) {
9
+ const result = await transformImage(data, options.transform);
10
+ payload = result.data;
11
+ if (options.meta) {
12
+ options.meta.mime = result.mime;
13
+ options.meta.width = result.width;
14
+ options.meta.height = result.height;
15
+ }
16
+ }
17
+ const { id } = await provider.create(groupId, payload, options.meta);
7
18
  return id;
8
19
  }
9
20
  async function list(groupId) {
@@ -7,6 +7,51 @@ export interface FileMeta {
7
7
  comment?: string;
8
8
  [key: string]: unknown;
9
9
  }
10
+ /** Output image format for {@link transformImage}. */
11
+ export type ImageFormat = 'webp' | 'png' | 'jpeg' | 'avif' | 'gif';
12
+ /**
13
+ * Options for upload-time image processing, backed by the optional `sharp`
14
+ * peer dependency. Passed via `useFileStorage().upload(.., { transform })` or
15
+ * to the standalone `transformImage()` util.
16
+ */
17
+ export interface ImageTransformOptions {
18
+ /** Target width in px. Combined with `fit` to bound the image. */
19
+ width?: number;
20
+ /** Target height in px. Combined with `fit` to bound the image. */
21
+ height?: number;
22
+ /**
23
+ * How the image is resized to fit `width`/`height`. Mirrors sharp's `fit`.
24
+ * Default: `'inside'` (preserve aspect ratio, fit within the box).
25
+ */
26
+ fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
27
+ /** Never scale the image up beyond its original size. Default: `true`. */
28
+ withoutEnlargement?: boolean;
29
+ /** Output format. Default: keep the input's format. */
30
+ format?: ImageFormat;
31
+ /** Output quality (1-100) for lossy formats (webp/jpeg/avif). */
32
+ quality?: number;
33
+ /**
34
+ * Preserve every frame of multi-frame inputs (animated webp/gif). Default:
35
+ * `true`; harmless for static images. Animation is only retained when the
36
+ * output `format` is animation-capable (`webp`/`gif`).
37
+ */
38
+ animated?: boolean;
39
+ /** Background used when flattening transparency (e.g. for `contain`/jpeg). */
40
+ background?: string;
41
+ }
42
+ /** Result of {@link transformImage}: the processed bytes plus resolved metadata. */
43
+ export interface ImageTransformResult {
44
+ /** The processed image bytes. */
45
+ data: Buffer;
46
+ /** MIME type of the processed image, e.g. `image/webp`. */
47
+ mime: string;
48
+ /** Resolved output format, e.g. `webp`. */
49
+ format: string;
50
+ /** Width of the processed image in px, if sharp could determine it. */
51
+ width?: number;
52
+ /** Height of the processed image in px, if sharp could determine it. */
53
+ height?: number;
54
+ }
10
55
  export interface ExternalRef {
11
56
  /** External system identifier, e.g. 'jira', 'sharepoint' */
12
57
  source: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-filer",
3
- "version": "0.0.6",
3
+ "version": "0.0.9",
4
4
  "description": "File storage module for Nuxt",
5
5
  "repository": {
6
6
  "type": "git",
@@ -64,13 +64,15 @@
64
64
  "eslint": "^10.1.0",
65
65
  "ipx": "^3.1.1",
66
66
  "nuxt": "^4.4.2",
67
+ "sharp": "^0.34.5",
67
68
  "typescript": "~6.0.2",
68
69
  "vitest": "^4.1.2",
69
70
  "vue-tsc": "^3.2.6"
70
71
  },
71
72
  "peerDependencies": {
72
73
  "@nuxt/image": "^2.0.0",
73
- "ipx": "^3.0.0"
74
+ "ipx": "^3.0.0",
75
+ "sharp": "^0.33.0 || ^0.34.0"
74
76
  },
75
77
  "peerDependenciesMeta": {
76
78
  "@nuxt/image": {
@@ -78,12 +80,16 @@
78
80
  },
79
81
  "ipx": {
80
82
  "optional": true
83
+ },
84
+ "sharp": {
85
+ "optional": true
81
86
  }
82
87
  },
83
88
  "packageManager": "pnpm@10.33.0",
84
89
  "pnpm": {
85
90
  "onlyBuiltDependencies": [
86
- "esbuild"
91
+ "esbuild",
92
+ "sharp"
87
93
  ]
88
94
  }
89
95
  }
@@ -1,4 +1,5 @@
1
1
  import type { ProviderGetImage } from '@nuxt/image';
2
+ import { joinURL } from 'ufo';
2
3
 
3
4
  interface FilerProviderOptions {
4
5
  baseURL?: string;
@@ -7,27 +8,44 @@ interface FilerProviderOptions {
7
8
  const operationsGenerator = (
8
9
  modifiers: Record<string, string | number | boolean | undefined>
9
10
  ): string => {
10
- const ops: string[] = [];
11
- for (const [key, value] of Object.entries(modifiers)) {
11
+ const out: string[] = [];
12
+ // Match @nuxt/image's built-in IPX provider: width+height collapses to resize.
13
+ if (modifiers.width && modifiers.height) {
14
+ modifiers.resize = `${modifiers.width}x${modifiers.height}`;
15
+ delete modifiers.width;
16
+ delete modifiers.height;
17
+ }
18
+ const keyMap: Record<string, string> = {
19
+ format: 'f',
20
+ width: 'w',
21
+ height: 'h',
22
+ resize: 's',
23
+ quality: 'q',
24
+ background: 'b',
25
+ position: 'pos',
26
+ };
27
+ for (const [rawKey, value] of Object.entries(modifiers)) {
12
28
  if (value === undefined || value === null || value === '' || value === false) continue;
13
- ops.push(`${key}_${value}`);
29
+ const key = keyMap[rawKey] ?? rawKey;
30
+ out.push(`${key}_${value}`);
14
31
  }
15
- return ops.length ? ops.join(',') : '_';
32
+ return out.length ? out.join(',') : '_';
16
33
  };
17
34
 
18
35
  export const getImage: ProviderGetImage = (
19
36
  src: string,
20
- { modifiers = {}, baseURL }: { modifiers?: Record<string, unknown>; baseURL?: string } = {},
21
- // ctx is provided by @nuxt/image but unused here
37
+ { modifiers = {}, baseURL = '/_filer-ipx' }: { modifiers?: Record<string, unknown>; baseURL?: string } = {},
22
38
  ) => {
23
- const root = (baseURL ?? '/_filer-ipx').replace(/\/+$/, '');
24
39
  const ops = operationsGenerator(
25
40
  modifiers as Record<string, string | number | boolean | undefined>
26
41
  );
27
- const path = src.replace(/^\/+/, '');
28
- return { url: `${root}/${ops}/${path}` };
42
+ return { url: joinURL(baseURL, ops, src.replace(/^\/+/, '')) };
29
43
  };
30
44
 
31
- export const validateDomains = false;
45
+ export default () => ({
46
+ getImage,
47
+ validateDomains: false,
48
+ supportsAlias: false,
49
+ });
32
50
 
33
51
  export type { FilerProviderOptions };
@@ -0,0 +1,156 @@
1
+ import {
2
+ existsSync,
3
+ promises as fsp,
4
+ type Dirent,
5
+ type Stats,
6
+ } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+
9
+ /**
10
+ * Drop-in replacement for unstorage's built-in `fs-lite` driver.
11
+ *
12
+ * `fs-lite` creates intermediate directories via a userspace `ensuredir`
13
+ * (recursive `existsSync` + non-recursive `mkdir`). In practice this is
14
+ * unreliable for first-time writes to a brand-new key path — we observed
15
+ * ENOENT on `writeFile` even though `ensuredir` should have run. The
16
+ * kernel's atomic `mkdir(..., { recursive: true })` is much more reliable
17
+ * across filesystems (including container-bound volumes).
18
+ *
19
+ * This driver mirrors the fs-lite public surface but always pre-creates
20
+ * the parent directory tree with a single recursive `mkdir` before any
21
+ * write, and tolerates missing files / dirs on reads.
22
+ *
23
+ * Implemented against unstorage's structural `Driver` contract; we avoid
24
+ * importing `defineDriver` so this stays decoupled from a specific
25
+ * unstorage major.
26
+ */
27
+
28
+ const PATH_TRAVERSE_RE = /\.\.:|\.\.$/;
29
+ const DRIVER_NAME = 'nuxt-filer-fs';
30
+
31
+ export interface FsDriverOptions {
32
+ /** Filesystem path used as the base for all keys. Required. */
33
+ base?: string;
34
+ /** Treat the storage as read-only; writes become no-ops. */
35
+ readOnly?: boolean;
36
+ /** Suppress `clear()`. */
37
+ noClear?: boolean;
38
+ /** Optional ignore filter applied during `getKeys`. */
39
+ ignore?: (path: string) => boolean;
40
+ }
41
+
42
+ function driverError(message: string): Error {
43
+ return new Error(`[nuxt-filer] [${DRIVER_NAME}] ${message}`);
44
+ }
45
+
46
+ function ignoreNotfound<T>(err: NodeJS.ErrnoException): T | null {
47
+ if (err.code === 'ENOENT' || err.code === 'EISDIR') return null;
48
+ throw err;
49
+ }
50
+
51
+ async function readdirSafe(dir: string): Promise<Dirent[]> {
52
+ try {
53
+ return await fsp.readdir(dir, { withFileTypes: true });
54
+ } catch (err) {
55
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ async function readdirRecursive(
61
+ dir: string,
62
+ ignore: ((p: string) => boolean) | undefined,
63
+ maxDepth: number | undefined,
64
+ ): Promise<string[]> {
65
+ if (ignore && ignore(dir)) return [];
66
+ const entries = await readdirSafe(dir);
67
+ const files: string[] = [];
68
+ await Promise.all(
69
+ entries.map(async (entry) => {
70
+ const entryPath = resolve(dir, entry.name);
71
+ if (entry.isDirectory()) {
72
+ if (maxDepth === undefined || maxDepth > 0) {
73
+ const nested = await readdirRecursive(
74
+ entryPath,
75
+ ignore,
76
+ maxDepth === undefined ? undefined : maxDepth - 1,
77
+ );
78
+ files.push(...nested.map((f) => entry.name + '/' + f));
79
+ }
80
+ } else if (!(ignore && ignore(entry.name))) {
81
+ files.push(entry.name);
82
+ }
83
+ }),
84
+ );
85
+ return files;
86
+ }
87
+
88
+ export default function fsDriver(opts: FsDriverOptions = {}) {
89
+ if (!opts.base) {
90
+ throw driverError('Missing required option `base`.');
91
+ }
92
+ const base = resolve(opts.base);
93
+
94
+ const r = (key: string) => {
95
+ if (PATH_TRAVERSE_RE.test(key)) {
96
+ throw driverError(
97
+ `Invalid key: ${JSON.stringify(key)}. It should not contain .. segments`,
98
+ );
99
+ }
100
+ return join(base, key.replace(/:/g, '/'));
101
+ };
102
+
103
+ async function write(path: string, value: string | Buffer | Uint8Array) {
104
+ await fsp.mkdir(dirname(path), { recursive: true });
105
+ await fsp.writeFile(path, value);
106
+ }
107
+
108
+ return {
109
+ name: DRIVER_NAME,
110
+ options: opts,
111
+ flags: { maxDepth: true },
112
+
113
+ hasItem(key: string) {
114
+ return existsSync(r(key));
115
+ },
116
+ getItem(key: string) {
117
+ return fsp
118
+ .readFile(r(key), 'utf8')
119
+ .catch((err) => ignoreNotfound<string>(err));
120
+ },
121
+ getItemRaw(key: string) {
122
+ return fsp
123
+ .readFile(r(key))
124
+ .catch((err) => ignoreNotfound<Buffer>(err));
125
+ },
126
+ async getMeta(key: string) {
127
+ const stat = (await fsp.stat(r(key)).catch(() => ({}))) as Partial<Stats>;
128
+ return {
129
+ atime: stat.atime,
130
+ mtime: stat.mtime,
131
+ size: stat.size,
132
+ birthtime: stat.birthtime,
133
+ ctime: stat.ctime,
134
+ };
135
+ },
136
+ async setItem(key: string, value: string) {
137
+ if (opts.readOnly) return;
138
+ await write(r(key), value);
139
+ },
140
+ async setItemRaw(key: string, value: Buffer | Uint8Array) {
141
+ if (opts.readOnly) return;
142
+ await write(r(key), value);
143
+ },
144
+ async removeItem(key: string) {
145
+ if (opts.readOnly) return;
146
+ await fsp.unlink(r(key)).catch((err) => ignoreNotfound(err));
147
+ },
148
+ getKeys(_base?: string, topts?: { maxDepth?: number }) {
149
+ return readdirRecursive(r('.'), opts.ignore, topts?.maxDepth);
150
+ },
151
+ async clear() {
152
+ if (opts.readOnly || opts.noClear) return;
153
+ await fsp.rm(r('.'), { recursive: true, force: true });
154
+ },
155
+ };
156
+ }
@@ -0,0 +1,15 @@
1
+ import { defineNitroPlugin, useStorage } from 'nitropack/runtime';
2
+ // @ts-expect-error virtual module
3
+ import { storageName, storagePath } from '#nuxt-filer-options';
4
+ import fsDriver from '../drivers/fs';
5
+
6
+ /**
7
+ * Mount the default filesystem-backed storage for nuxt-filer. We mount
8
+ * via a Nitro plugin rather than `nitroConfig.storage` so that our
9
+ * custom fs driver is bundled with the plugin and there is no runtime
10
+ * module resolution against the package's `dist/`.
11
+ */
12
+ export default defineNitroPlugin(() => {
13
+ const storage = useStorage();
14
+ storage.mount(storageName, fsDriver({ base: storagePath }));
15
+ });
@@ -0,0 +1,82 @@
1
+ import type {
2
+ ImageFormat,
3
+ ImageTransformOptions,
4
+ ImageTransformResult,
5
+ } from '../../../runtime/types';
6
+
7
+ export type { ImageFormat, ImageTransformOptions, ImageTransformResult };
8
+
9
+ const MIME_BY_FORMAT: Record<string, string> = {
10
+ webp: 'image/webp',
11
+ png: 'image/png',
12
+ jpeg: 'image/jpeg',
13
+ jpg: 'image/jpeg',
14
+ avif: 'image/avif',
15
+ gif: 'image/gif',
16
+ };
17
+
18
+ // `sharp` is an optional peer dependency — resolve it lazily and cache the
19
+ // module so unrelated installs never pay for the native binary.
20
+ type SharpModule = typeof import('sharp');
21
+ let sharpModule: SharpModule | undefined;
22
+
23
+ async function loadSharp(): Promise<SharpModule> {
24
+ if (sharpModule) return sharpModule;
25
+ try {
26
+ sharpModule = (await import('sharp')).default as unknown as SharpModule;
27
+ }
28
+ catch {
29
+ throw new Error(
30
+ '[nuxt-filer] Image transforms require the optional peer dependency `sharp`. Install it with `npm i sharp`.'
31
+ );
32
+ }
33
+ return sharpModule;
34
+ }
35
+
36
+ /**
37
+ * Process an image buffer with sharp: resize within a box, convert format,
38
+ * optimize, and (by default) preserve animation for multi-frame inputs. Used
39
+ * for upload-time normalization — see `useFileStorage().upload({ transform })`.
40
+ *
41
+ * Requires the optional `sharp` peer dependency; throws a clear error if it is
42
+ * not installed.
43
+ */
44
+ export async function transformImage(
45
+ data: Buffer | Uint8Array,
46
+ options: ImageTransformOptions = {}
47
+ ): Promise<ImageTransformResult> {
48
+ const sharp = await loadSharp();
49
+
50
+ const animated = options.animated ?? true;
51
+ let pipeline = sharp(data, { animated });
52
+
53
+ if (options.width != null || options.height != null) {
54
+ pipeline = pipeline.resize({
55
+ width: options.width,
56
+ height: options.height,
57
+ fit: options.fit ?? 'inside',
58
+ withoutEnlargement: options.withoutEnlargement ?? true,
59
+ background: options.background,
60
+ });
61
+ }
62
+
63
+ if (options.format) {
64
+ const formatOptions
65
+ = options.quality != null ? { quality: options.quality } : {};
66
+ pipeline = pipeline.toFormat(
67
+ options.format as keyof import('sharp').FormatEnum,
68
+ formatOptions
69
+ );
70
+ }
71
+
72
+ const out = await pipeline.toBuffer({ resolveWithObject: true });
73
+ const format = out.info.format;
74
+
75
+ return {
76
+ data: out.data,
77
+ format,
78
+ mime: MIME_BY_FORMAT[format] ?? `image/${format}`,
79
+ width: out.info.width,
80
+ height: out.info.height,
81
+ };
82
+ }
@@ -4,10 +4,20 @@ import type {
4
4
  StoredFile,
5
5
  ExternalRef,
6
6
  FileStorageProvider,
7
+ ImageTransformOptions,
8
+ ImageTransformResult,
7
9
  } from '../../../runtime/types';
8
10
  import { useFileStorageProvider } from '../provider';
11
+ import { transformImage } from './image';
9
12
 
10
- export type { FileMeta, StoredFile, ExternalRef, FileStorageProvider };
13
+ export type {
14
+ FileMeta,
15
+ StoredFile,
16
+ ExternalRef,
17
+ FileStorageProvider,
18
+ ImageTransformOptions,
19
+ ImageTransformResult,
20
+ };
11
21
 
12
22
  export const useFileStorage = () => {
13
23
  const provider = useFileStorageProvider();
@@ -15,9 +25,23 @@ export const useFileStorage = () => {
15
25
  async function upload(
16
26
  groupId: string,
17
27
  data: Buffer | Uint8Array,
18
- options: { meta?: FileMeta } = {}
28
+ options: { meta?: FileMeta; transform?: ImageTransformOptions } = {}
19
29
  ): Promise<string> {
20
- const { id } = await provider.create(groupId, data, options.meta);
30
+ let payload = data;
31
+
32
+ // Optional upload-time image processing (requires the `sharp` peer dep).
33
+ // The stored bytes and the metadata's mime/dimensions reflect the result.
34
+ if (options.transform) {
35
+ const result = await transformImage(data, options.transform);
36
+ payload = result.data;
37
+ if (options.meta) {
38
+ options.meta.mime = result.mime;
39
+ options.meta.width = result.width;
40
+ options.meta.height = result.height;
41
+ }
42
+ }
43
+
44
+ const { id } = await provider.create(groupId, payload, options.meta);
21
45
  return id;
22
46
  }
23
47
 
@@ -8,6 +8,54 @@ export interface FileMeta {
8
8
  [key: string]: unknown;
9
9
  }
10
10
 
11
+ /** Output image format for {@link transformImage}. */
12
+ export type ImageFormat = 'webp' | 'png' | 'jpeg' | 'avif' | 'gif';
13
+
14
+ /**
15
+ * Options for upload-time image processing, backed by the optional `sharp`
16
+ * peer dependency. Passed via `useFileStorage().upload(.., { transform })` or
17
+ * to the standalone `transformImage()` util.
18
+ */
19
+ export interface ImageTransformOptions {
20
+ /** Target width in px. Combined with `fit` to bound the image. */
21
+ width?: number;
22
+ /** Target height in px. Combined with `fit` to bound the image. */
23
+ height?: number;
24
+ /**
25
+ * How the image is resized to fit `width`/`height`. Mirrors sharp's `fit`.
26
+ * Default: `'inside'` (preserve aspect ratio, fit within the box).
27
+ */
28
+ fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
29
+ /** Never scale the image up beyond its original size. Default: `true`. */
30
+ withoutEnlargement?: boolean;
31
+ /** Output format. Default: keep the input's format. */
32
+ format?: ImageFormat;
33
+ /** Output quality (1-100) for lossy formats (webp/jpeg/avif). */
34
+ quality?: number;
35
+ /**
36
+ * Preserve every frame of multi-frame inputs (animated webp/gif). Default:
37
+ * `true`; harmless for static images. Animation is only retained when the
38
+ * output `format` is animation-capable (`webp`/`gif`).
39
+ */
40
+ animated?: boolean;
41
+ /** Background used when flattening transparency (e.g. for `contain`/jpeg). */
42
+ background?: string;
43
+ }
44
+
45
+ /** Result of {@link transformImage}: the processed bytes plus resolved metadata. */
46
+ export interface ImageTransformResult {
47
+ /** The processed image bytes. */
48
+ data: Buffer;
49
+ /** MIME type of the processed image, e.g. `image/webp`. */
50
+ mime: string;
51
+ /** Resolved output format, e.g. `webp`. */
52
+ format: string;
53
+ /** Width of the processed image in px, if sharp could determine it. */
54
+ width?: number;
55
+ /** Height of the processed image in px, if sharp could determine it. */
56
+ height?: number;
57
+ }
58
+
11
59
  export interface ExternalRef {
12
60
  /** External system identifier, e.g. 'jira', 'sharepoint' */
13
61
  source: string;