nuxt-filer 0.0.9 → 0.0.12
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 +24 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +4 -0
- package/dist/runtime/server/handlers/ipx.d.ts +2 -5
- package/dist/runtime/server/handlers/ipx.js +3 -9
- package/dist/runtime/server/utils/send.d.ts +33 -0
- package/dist/runtime/server/utils/send.js +64 -0
- package/package.json +11 -19
- package/src/runtime/server/handlers/ipx.ts +11 -15
- package/src/runtime/server/utils/send.ts +135 -0
package/README.md
CHANGED
|
@@ -136,6 +136,30 @@ const res = await transformImage(buffer, { width: 64, format: 'webp' })
|
|
|
136
136
|
| `external?.push(groupId, id, data, meta)` | Push to external system |
|
|
137
137
|
| `external?.pull(groupId, ref)` | Pull from external system |
|
|
138
138
|
|
|
139
|
+
### Serving raw files with `sendStoredFile()`
|
|
140
|
+
|
|
141
|
+
The IPX route serves **images** with full HTTP caching. For everything else — original PDFs, non-image downloads, the unprocessed bytes of any file — `sendStoredFile()` streams a stored file back through an H3 event with the same revalidation story.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
// server/api/files/[groupId]/[id].get.ts
|
|
145
|
+
export default defineEventHandler((event) => {
|
|
146
|
+
const { groupId, id } = getRouterParams(event)
|
|
147
|
+
return sendStoredFile(event, groupId, id) // 404 if missing
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
It sets `content-type` from `meta.mime`, a `content-disposition` filename from `meta.name`, and `cache-control` / `last-modified` / `etag`, honoring `if-modified-since` / `if-none-match` (`304`) — just like the IPX route. `HEAD` requests get the headers without a body.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
sendStoredFile(event, groupId, id, {
|
|
155
|
+
disposition: 'attachment', // force a download ('inline' is the default)
|
|
156
|
+
filename: 'invoice-2026.pdf', // override the download name (default: meta.name)
|
|
157
|
+
maxAge: 3600, // cache-control max-age in seconds (default: 1 year; 0 = no-cache)
|
|
158
|
+
})
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`sendStoredFile` is auto-imported in your server routes — no need to import it.
|
|
162
|
+
|
|
139
163
|
## `@nuxt/image` Integration
|
|
140
164
|
|
|
141
165
|
If `@nuxt/image` is installed alongside `nuxt-filer`, the module automatically registers a `filer` image provider and an IPX endpoint that pulls bytes from your storage provider, runs them through Sharp, and returns the result.
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -22,6 +22,10 @@ const module = defineNuxtModule({
|
|
|
22
22
|
{
|
|
23
23
|
name: "transformImage",
|
|
24
24
|
from: resolver.resolve("./runtime/server/utils/image")
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "sendStoredFile",
|
|
28
|
+
from: resolver.resolve("./runtime/server/utils/send")
|
|
25
29
|
}
|
|
26
30
|
]);
|
|
27
31
|
const providerSpecifier = resolver.resolve("./runtime/server/provider");
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
message: string;
|
|
4
|
-
};
|
|
5
|
-
}>>;
|
|
1
|
+
import { type EventHandler } from 'h3';
|
|
2
|
+
declare const _default: EventHandler<import("h3").EventHandlerRequest, any>;
|
|
6
3
|
export default _default;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineEventHandler } from "h3";
|
|
1
|
+
import { defineEventHandler, useBase } from "h3";
|
|
2
2
|
import { createIPX, createIPXH3Handler } from "ipx";
|
|
3
3
|
import { ipxRoute } from "#nuxt-filer-image";
|
|
4
4
|
import { useFileStorageProvider } from "../provider.js";
|
|
@@ -36,14 +36,8 @@ let _ipx = null;
|
|
|
36
36
|
function getHandler() {
|
|
37
37
|
if (!_handler) {
|
|
38
38
|
_ipx = createIPX({ storage: filerStorage });
|
|
39
|
-
_handler = createIPXH3Handler(_ipx);
|
|
39
|
+
_handler = useBase(ipxRoute, createIPXH3Handler(_ipx));
|
|
40
40
|
}
|
|
41
41
|
return _handler;
|
|
42
42
|
}
|
|
43
|
-
export default defineEventHandler((event) =>
|
|
44
|
-
const original = event.path;
|
|
45
|
-
let sub = original.startsWith(ipxRoute) ? original.slice(ipxRoute.length) : original;
|
|
46
|
-
if (!sub.startsWith("/")) sub = `/${sub}`;
|
|
47
|
-
event.path = sub;
|
|
48
|
-
return getHandler()(event);
|
|
49
|
-
});
|
|
43
|
+
export default defineEventHandler((event) => getHandler()(event));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type H3Event } from 'h3';
|
|
2
|
+
import type { StoredFile } from '../../../runtime/types.js';
|
|
3
|
+
export type { StoredFile };
|
|
4
|
+
export interface SendStoredFileOptions {
|
|
5
|
+
/**
|
|
6
|
+
* `content-disposition` type. `'inline'` (the default) lets the browser
|
|
7
|
+
* render the file in place; `'attachment'` forces a download.
|
|
8
|
+
*/
|
|
9
|
+
disposition?: 'inline' | 'attachment';
|
|
10
|
+
/** Override the download filename. Defaults to the stored `meta.name`. */
|
|
11
|
+
filename?: string;
|
|
12
|
+
/**
|
|
13
|
+
* `cache-control` max-age in seconds. Default: one year — matching the IPX
|
|
14
|
+
* route. Pass `0` to mark the response uncacheable.
|
|
15
|
+
*/
|
|
16
|
+
maxAge?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Stream a stored file back through an H3 event with the same HTTP revalidation
|
|
20
|
+
* story as the IPX image route: `content-type` from `meta.mime`, a
|
|
21
|
+
* `content-disposition` filename from `meta.name`, and
|
|
22
|
+
* `cache-control` / `last-modified` / `etag` honoring `if-modified-since` /
|
|
23
|
+
* `if-none-match` (304). Throws a 404 when the file does not exist.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* // server/api/files/[groupId]/[id].get.ts
|
|
27
|
+
* export default defineEventHandler((event) => {
|
|
28
|
+
* const { groupId, id } = getRouterParams(event)
|
|
29
|
+
* return sendStoredFile(event, groupId, id)
|
|
30
|
+
* })
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export declare function sendStoredFile(event: H3Event, groupId: string, id: string, options?: SendStoredFileOptions): Promise<Buffer | null>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getRequestHeader,
|
|
3
|
+
setResponseHeader,
|
|
4
|
+
setResponseStatus,
|
|
5
|
+
createError
|
|
6
|
+
} from "h3";
|
|
7
|
+
import { useFileStorageProvider } from "../provider.js";
|
|
8
|
+
const DEFAULT_MAX_AGE = 60 * 60 * 24 * 365;
|
|
9
|
+
function fileMtime(file) {
|
|
10
|
+
return file.updatedAt ?? file.createdAt ?? void 0;
|
|
11
|
+
}
|
|
12
|
+
function cacheValidators(file) {
|
|
13
|
+
const mtime = fileMtime(file);
|
|
14
|
+
if (!mtime) return {};
|
|
15
|
+
return { etag: `W/"${mtime.getTime().toString(16)}"`, lastModified: mtime };
|
|
16
|
+
}
|
|
17
|
+
function contentDisposition(type, name) {
|
|
18
|
+
const asciiName = name.replace(/[^\x20-\x7E]|["\\]/g, "_");
|
|
19
|
+
return `${type}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
20
|
+
}
|
|
21
|
+
export async function sendStoredFile(event, groupId, id, options = {}) {
|
|
22
|
+
const provider = useFileStorageProvider();
|
|
23
|
+
const file = await provider.get(groupId, id);
|
|
24
|
+
if (!file) {
|
|
25
|
+
throw createError({ statusCode: 404, statusMessage: "File not found" });
|
|
26
|
+
}
|
|
27
|
+
const maxAge = options.maxAge ?? DEFAULT_MAX_AGE;
|
|
28
|
+
const { etag, lastModified } = cacheValidators(file);
|
|
29
|
+
setResponseHeader(
|
|
30
|
+
event,
|
|
31
|
+
"cache-control",
|
|
32
|
+
maxAge > 0 ? `public, max-age=${maxAge}` : "no-cache"
|
|
33
|
+
);
|
|
34
|
+
if (etag) setResponseHeader(event, "etag", etag);
|
|
35
|
+
if (lastModified)
|
|
36
|
+
setResponseHeader(event, "last-modified", lastModified.toUTCString());
|
|
37
|
+
const ifNoneMatch = getRequestHeader(event, "if-none-match");
|
|
38
|
+
const ifModifiedSince = getRequestHeader(event, "if-modified-since");
|
|
39
|
+
const notModified = etag ? ifNoneMatch === etag : !!lastModified && !!ifModifiedSince && lastModified.getTime() <= Date.parse(ifModifiedSince);
|
|
40
|
+
if (notModified) {
|
|
41
|
+
setResponseStatus(event, 304);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
setResponseHeader(
|
|
45
|
+
event,
|
|
46
|
+
"content-type",
|
|
47
|
+
file.meta.mime || "application/octet-stream"
|
|
48
|
+
);
|
|
49
|
+
setResponseHeader(
|
|
50
|
+
event,
|
|
51
|
+
"content-disposition",
|
|
52
|
+
contentDisposition(
|
|
53
|
+
options.disposition ?? "inline",
|
|
54
|
+
options.filename ?? file.meta.name
|
|
55
|
+
)
|
|
56
|
+
);
|
|
57
|
+
const data = await provider.getData(groupId, id);
|
|
58
|
+
if (!data) {
|
|
59
|
+
throw createError({ statusCode: 404, statusMessage: "File not found" });
|
|
60
|
+
}
|
|
61
|
+
setResponseHeader(event, "content-length", data.length);
|
|
62
|
+
if (event.method === "HEAD") return null;
|
|
63
|
+
return data;
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-filer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"description": "File storage module for Nuxt",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,17 +36,6 @@
|
|
|
36
36
|
"dist",
|
|
37
37
|
"src/runtime"
|
|
38
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
39
|
"dependencies": {
|
|
51
40
|
"@nuxt/kit": "^4.4.2",
|
|
52
41
|
"consola": "^3.4.2",
|
|
@@ -85,11 +74,14 @@
|
|
|
85
74
|
"optional": true
|
|
86
75
|
}
|
|
87
76
|
},
|
|
88
|
-
"
|
|
89
|
-
|
|
90
|
-
"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
77
|
+
"scripts": {
|
|
78
|
+
"dev": "npm run dev:prepare && nuxi dev playground",
|
|
79
|
+
"dev:build": "nuxi build playground",
|
|
80
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
|
|
81
|
+
"release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
|
|
82
|
+
"lint": "eslint .",
|
|
83
|
+
"test": "vitest run",
|
|
84
|
+
"test:watch": "vitest watch",
|
|
85
|
+
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
94
86
|
}
|
|
95
|
-
}
|
|
87
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineEventHandler } from 'h3';
|
|
1
|
+
import { defineEventHandler, useBase, type EventHandler } from 'h3';
|
|
2
2
|
import { createIPX, createIPXH3Handler, type IPX, type IPXStorage } from 'ipx';
|
|
3
3
|
// @ts-expect-error virtual module injected by the module
|
|
4
4
|
import { ipxRoute } from '#nuxt-filer-image';
|
|
@@ -41,24 +41,20 @@ const filerStorage: IPXStorage = {
|
|
|
41
41
|
},
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
let _handler:
|
|
44
|
+
let _handler: EventHandler | null = null;
|
|
45
45
|
let _ipx: IPX | null = null;
|
|
46
|
-
function getHandler() {
|
|
46
|
+
function getHandler(): EventHandler {
|
|
47
47
|
if (!_handler) {
|
|
48
48
|
_ipx = createIPX({ storage: filerStorage });
|
|
49
|
-
|
|
49
|
+
// IPX expects to see `/<modifiers>/<groupId>/<fileId>`, so strip the
|
|
50
|
+
// configured base prefix first. We delegate to h3's `useBase` rather than
|
|
51
|
+
// assigning `event.path` — `event.path` is a getter-only accessor (it
|
|
52
|
+
// reads back `event._path || req.url`), so writing to it throws
|
|
53
|
+
// "Cannot set property path ... which has only a getter". `useBase`
|
|
54
|
+
// rewrites `_path`/`req.url` for the inner handler and restores them after.
|
|
55
|
+
_handler = useBase(ipxRoute, createIPXH3Handler(_ipx));
|
|
50
56
|
}
|
|
51
57
|
return _handler;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
export default defineEventHandler((event) =>
|
|
55
|
-
// Strip the configured base prefix so IPX sees the URL it expects:
|
|
56
|
-
// `/<modifiers>/<groupId>/<fileId>`.
|
|
57
|
-
const original = event.path;
|
|
58
|
-
let sub = original.startsWith(ipxRoute)
|
|
59
|
-
? original.slice((ipxRoute as string).length)
|
|
60
|
-
: original;
|
|
61
|
-
if (!sub.startsWith('/')) sub = `/${sub}`;
|
|
62
|
-
event.path = sub;
|
|
63
|
-
return getHandler()(event);
|
|
64
|
-
});
|
|
60
|
+
export default defineEventHandler((event) => getHandler()(event));
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type H3Event,
|
|
3
|
+
getRequestHeader,
|
|
4
|
+
setResponseHeader,
|
|
5
|
+
setResponseStatus,
|
|
6
|
+
createError,
|
|
7
|
+
} from 'h3';
|
|
8
|
+
import type { StoredFile } from '../../../runtime/types';
|
|
9
|
+
import { useFileStorageProvider } from '../provider';
|
|
10
|
+
|
|
11
|
+
export type { StoredFile };
|
|
12
|
+
|
|
13
|
+
export interface SendStoredFileOptions {
|
|
14
|
+
/**
|
|
15
|
+
* `content-disposition` type. `'inline'` (the default) lets the browser
|
|
16
|
+
* render the file in place; `'attachment'` forces a download.
|
|
17
|
+
*/
|
|
18
|
+
disposition?: 'inline' | 'attachment';
|
|
19
|
+
/** Override the download filename. Defaults to the stored `meta.name`. */
|
|
20
|
+
filename?: string;
|
|
21
|
+
/**
|
|
22
|
+
* `cache-control` max-age in seconds. Default: one year — matching the IPX
|
|
23
|
+
* route. Pass `0` to mark the response uncacheable.
|
|
24
|
+
*/
|
|
25
|
+
maxAge?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_MAX_AGE = 60 * 60 * 24 * 365; // 1 year, matching the IPX route.
|
|
29
|
+
|
|
30
|
+
/** The mtime the IPX route also uses: prefer `updatedAt`, fall back to `createdAt`. */
|
|
31
|
+
function fileMtime(file: StoredFile): Date | undefined {
|
|
32
|
+
return file.updatedAt ?? file.createdAt ?? undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a weak validator from the file's mtime so revalidation can be answered
|
|
37
|
+
* without reading the bytes back. Mirrors the IPX route, which keys its etag /
|
|
38
|
+
* last-modified off the same mtime.
|
|
39
|
+
*/
|
|
40
|
+
function cacheValidators(file: StoredFile): {
|
|
41
|
+
etag?: string;
|
|
42
|
+
lastModified?: Date;
|
|
43
|
+
} {
|
|
44
|
+
const mtime = fileMtime(file);
|
|
45
|
+
if (!mtime) return {};
|
|
46
|
+
return { etag: `W/"${mtime.getTime().toString(16)}"`, lastModified: mtime };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** RFC 6266 `content-disposition` value with an ASCII fallback + UTF-8 form. */
|
|
50
|
+
function contentDisposition(type: string, name: string): string {
|
|
51
|
+
// Strip anything outside printable ASCII (plus quote/backslash) for the
|
|
52
|
+
// legacy `filename=`; the `filename*=` form carries the real UTF-8 name.
|
|
53
|
+
const asciiName = name.replace(/[^\x20-\x7E]|["\\]/g, '_');
|
|
54
|
+
return `${type}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stream a stored file back through an H3 event with the same HTTP revalidation
|
|
59
|
+
* story as the IPX image route: `content-type` from `meta.mime`, a
|
|
60
|
+
* `content-disposition` filename from `meta.name`, and
|
|
61
|
+
* `cache-control` / `last-modified` / `etag` honoring `if-modified-since` /
|
|
62
|
+
* `if-none-match` (304). Throws a 404 when the file does not exist.
|
|
63
|
+
*
|
|
64
|
+
* ```ts
|
|
65
|
+
* // server/api/files/[groupId]/[id].get.ts
|
|
66
|
+
* export default defineEventHandler((event) => {
|
|
67
|
+
* const { groupId, id } = getRouterParams(event)
|
|
68
|
+
* return sendStoredFile(event, groupId, id)
|
|
69
|
+
* })
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export async function sendStoredFile(
|
|
73
|
+
event: H3Event,
|
|
74
|
+
groupId: string,
|
|
75
|
+
id: string,
|
|
76
|
+
options: SendStoredFileOptions = {}
|
|
77
|
+
): Promise<Buffer | null> {
|
|
78
|
+
const provider = useFileStorageProvider();
|
|
79
|
+
|
|
80
|
+
const file = await provider.get(groupId, id);
|
|
81
|
+
if (!file) {
|
|
82
|
+
throw createError({ statusCode: 404, statusMessage: 'File not found' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const maxAge = options.maxAge ?? DEFAULT_MAX_AGE;
|
|
86
|
+
const { etag, lastModified } = cacheValidators(file);
|
|
87
|
+
|
|
88
|
+
setResponseHeader(
|
|
89
|
+
event,
|
|
90
|
+
'cache-control',
|
|
91
|
+
maxAge > 0 ? `public, max-age=${maxAge}` : 'no-cache'
|
|
92
|
+
);
|
|
93
|
+
if (etag) setResponseHeader(event, 'etag', etag);
|
|
94
|
+
if (lastModified)
|
|
95
|
+
setResponseHeader(event, 'last-modified', lastModified.toUTCString());
|
|
96
|
+
|
|
97
|
+
// Conditional request handling — answer 304 before touching the bytes.
|
|
98
|
+
const ifNoneMatch = getRequestHeader(event, 'if-none-match');
|
|
99
|
+
const ifModifiedSince = getRequestHeader(event, 'if-modified-since');
|
|
100
|
+
const notModified = etag
|
|
101
|
+
? ifNoneMatch === etag
|
|
102
|
+
: !!lastModified
|
|
103
|
+
&& !!ifModifiedSince
|
|
104
|
+
&& lastModified.getTime() <= Date.parse(ifModifiedSince);
|
|
105
|
+
if (notModified) {
|
|
106
|
+
setResponseStatus(event, 304);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setResponseHeader(
|
|
111
|
+
event,
|
|
112
|
+
'content-type',
|
|
113
|
+
file.meta.mime || 'application/octet-stream'
|
|
114
|
+
);
|
|
115
|
+
setResponseHeader(
|
|
116
|
+
event,
|
|
117
|
+
'content-disposition',
|
|
118
|
+
contentDisposition(
|
|
119
|
+
options.disposition ?? 'inline',
|
|
120
|
+
options.filename ?? file.meta.name
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const data = await provider.getData(groupId, id);
|
|
125
|
+
if (!data) {
|
|
126
|
+
throw createError({ statusCode: 404, statusMessage: 'File not found' });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
setResponseHeader(event, 'content-length', data.length);
|
|
130
|
+
|
|
131
|
+
// HEAD requests get the full header set but no body.
|
|
132
|
+
if (event.method === 'HEAD') return null;
|
|
133
|
+
|
|
134
|
+
return data;
|
|
135
|
+
}
|