@byline/host-tanstack-start 1.7.0 → 1.7.1
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.
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export interface UploadsHandlerOptions {
|
|
9
|
+
/**
|
|
10
|
+
* URL prefix to match. Trailing slash required. Default `'/uploads/'`.
|
|
11
|
+
* Must match the `baseUrl` you passed to `localStorageProvider`.
|
|
12
|
+
*/
|
|
13
|
+
prefix?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Filesystem directory to serve. Default `<process.cwd()>/uploads`.
|
|
16
|
+
* Must match the `uploadDir` you passed to `localStorageProvider`.
|
|
17
|
+
*/
|
|
18
|
+
dir?: string;
|
|
19
|
+
/**
|
|
20
|
+
* `Cache-Control` header. Default
|
|
21
|
+
* `'public, max-age=31536000, immutable'` — safe because the local
|
|
22
|
+
* provider UUID-prefixes filenames, so a given URL never points at
|
|
23
|
+
* different bytes across uploads.
|
|
24
|
+
*/
|
|
25
|
+
cacheControl?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Extra `extension → MIME type` entries merged on top of the built-in
|
|
28
|
+
* map. Use this to add formats the default map doesn't cover.
|
|
29
|
+
*/
|
|
30
|
+
mimeTypes?: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Build a `/uploads/*` handler with custom configuration. Returns a
|
|
34
|
+
* function suitable for use inside `createServerEntry({ fetch })` —
|
|
35
|
+
* resolves to a `Response` when the request matched and the file
|
|
36
|
+
* existed, and to `null` when the request should fall through to the
|
|
37
|
+
* framework handler.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createUploadsHandler(options?: UploadsHandlerOptions): (request: Request) => Promise<Response | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Default-configured `/uploads/*` handler. Matches
|
|
42
|
+
* `localStorageProvider({ uploadDir: './uploads', baseUrl: '/uploads' })`
|
|
43
|
+
* — the shape produced by `@byline/cli` host scaffolding.
|
|
44
|
+
*
|
|
45
|
+
* Resolved at module-load time, which fixes `process.cwd()` to the
|
|
46
|
+
* server's startup directory.
|
|
47
|
+
*/
|
|
48
|
+
export declare const serveUploads: (request: Request) => Promise<Response | null>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
const DEFAULT_MIME = {
|
|
4
|
+
'.avif': 'image/avif',
|
|
5
|
+
'.webp': 'image/webp',
|
|
6
|
+
'.jpg': 'image/jpeg',
|
|
7
|
+
'.jpeg': 'image/jpeg',
|
|
8
|
+
'.png': 'image/png',
|
|
9
|
+
'.gif': 'image/gif',
|
|
10
|
+
'.svg': 'image/svg+xml',
|
|
11
|
+
'.mp4': 'video/mp4',
|
|
12
|
+
'.webm': 'video/webm',
|
|
13
|
+
'.pdf': 'application/pdf',
|
|
14
|
+
'.json': 'application/json',
|
|
15
|
+
'.txt': 'text/plain; charset=utf-8'
|
|
16
|
+
};
|
|
17
|
+
const DEFAULT_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
18
|
+
function createUploadsHandler(options = {}) {
|
|
19
|
+
const prefix = options.prefix ?? '/uploads/';
|
|
20
|
+
const dir = resolve(options.dir ?? `${process.cwd()}/uploads`);
|
|
21
|
+
const cacheControl = options.cacheControl ?? DEFAULT_CACHE_CONTROL;
|
|
22
|
+
const mime = options.mimeTypes ? {
|
|
23
|
+
...DEFAULT_MIME,
|
|
24
|
+
...options.mimeTypes
|
|
25
|
+
} : DEFAULT_MIME;
|
|
26
|
+
return async function(request) {
|
|
27
|
+
if ('GET' !== request.method && 'HEAD' !== request.method) return null;
|
|
28
|
+
const url = new URL(request.url);
|
|
29
|
+
if (!url.pathname.startsWith(prefix)) return null;
|
|
30
|
+
let rel;
|
|
31
|
+
try {
|
|
32
|
+
rel = decodeURIComponent(url.pathname.slice(prefix.length));
|
|
33
|
+
} catch {
|
|
34
|
+
return new Response('Bad Request', {
|
|
35
|
+
status: 400
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (!rel) return null;
|
|
39
|
+
const abs = resolve(dir, rel);
|
|
40
|
+
if (abs !== dir && !abs.startsWith(`${dir}/`)) return new Response('Forbidden', {
|
|
41
|
+
status: 403
|
|
42
|
+
});
|
|
43
|
+
let info;
|
|
44
|
+
try {
|
|
45
|
+
info = await stat(abs);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!info.isFile()) return null;
|
|
50
|
+
const dot = abs.lastIndexOf('.');
|
|
51
|
+
const ext = dot >= 0 ? abs.slice(dot).toLowerCase() : '';
|
|
52
|
+
const type = mime[ext] || 'application/octet-stream';
|
|
53
|
+
const headers = new Headers({
|
|
54
|
+
'Content-Type': type,
|
|
55
|
+
'Content-Length': info.size.toString(),
|
|
56
|
+
'Last-Modified': new Date(info.mtimeMs).toUTCString(),
|
|
57
|
+
'Cache-Control': cacheControl
|
|
58
|
+
});
|
|
59
|
+
if ('HEAD' === request.method) return new Response(null, {
|
|
60
|
+
headers
|
|
61
|
+
});
|
|
62
|
+
const body = await readFile(abs);
|
|
63
|
+
return new Response(body, {
|
|
64
|
+
headers
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const serveUploads = createUploadsHandler();
|
|
69
|
+
export { createUploadsHandler, serveUploads };
|
|
@@ -16,7 +16,7 @@ export declare const listRegisteredAbilities: import("@tanstack/react-start").Op
|
|
|
16
16
|
label: string;
|
|
17
17
|
description: string | null;
|
|
18
18
|
group: string;
|
|
19
|
-
source: "
|
|
19
|
+
source: "admin" | "collection" | "plugin" | "core" | null;
|
|
20
20
|
}[];
|
|
21
21
|
groups: {
|
|
22
22
|
group: string;
|
|
@@ -25,7 +25,7 @@ export declare const listRegisteredAbilities: import("@tanstack/react-start").Op
|
|
|
25
25
|
label: string;
|
|
26
26
|
description: string | null;
|
|
27
27
|
group: string;
|
|
28
|
-
source: "
|
|
28
|
+
source: "admin" | "collection" | "plugin" | "core" | null;
|
|
29
29
|
}[];
|
|
30
30
|
}[];
|
|
31
31
|
total: number;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "1.7.
|
|
6
|
+
"version": "1.7.1",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -101,11 +101,11 @@
|
|
|
101
101
|
"react-swipeable": "^7.0.2",
|
|
102
102
|
"uuid": "^14.0.0",
|
|
103
103
|
"zod": "^4.4.2",
|
|
104
|
-
"@byline/
|
|
105
|
-
"@byline/
|
|
106
|
-
"@byline/
|
|
107
|
-
"@byline/
|
|
108
|
-
"@byline/ui": "1.7.
|
|
104
|
+
"@byline/admin": "1.7.1",
|
|
105
|
+
"@byline/client": "1.7.1",
|
|
106
|
+
"@byline/core": "1.7.1",
|
|
107
|
+
"@byline/auth": "1.7.1",
|
|
108
|
+
"@byline/ui": "1.7.1"
|
|
109
109
|
},
|
|
110
110
|
"peerDependencies": {
|
|
111
111
|
"@tanstack/react-router": "^1.167.0",
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runtime `/uploads/*` handler for TanStack Start + Nitro hosts.
|
|
11
|
+
*
|
|
12
|
+
* The local storage provider writes to a directory on disk; this helper
|
|
13
|
+
* streams that directory back on every request so newly-uploaded files
|
|
14
|
+
* appear without a rebuild.
|
|
15
|
+
*
|
|
16
|
+
* Why not `nitro.publicAssets`? `publicAssets` is a build-time copy
|
|
17
|
+
* (`copyPublicAssets` in `nitro/_build/common.mjs`) and the static handler
|
|
18
|
+
* reads from a virtual asset registry baked at build time
|
|
19
|
+
* (`runtime/internal/static.mjs` → `getAsset(id)` from
|
|
20
|
+
* `#nitro/virtual/public-assets`). Files written after the build never
|
|
21
|
+
* land in that registry, so they 404 forever. A request-time handler is
|
|
22
|
+
* the only correct shape for user-uploaded content.
|
|
23
|
+
*
|
|
24
|
+
* Usage in `src/server.ts`:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
|
|
28
|
+
* import { serveUploads } from '@byline/host-tanstack-start/integrations/serve-uploads'
|
|
29
|
+
*
|
|
30
|
+
* export default createServerEntry({
|
|
31
|
+
* async fetch(request) {
|
|
32
|
+
* const upload = await serveUploads(request)
|
|
33
|
+
* if (upload) return upload
|
|
34
|
+
* return handler.fetch(request)
|
|
35
|
+
* },
|
|
36
|
+
* })
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* The default-configured `serveUploads` matches
|
|
40
|
+
* `localStorageProvider({ uploadDir: './uploads', baseUrl: '/uploads' })`.
|
|
41
|
+
* For a different directory or URL prefix, build your own with
|
|
42
|
+
* `createUploadsHandler({ dir, prefix })`.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
46
|
+
import { resolve as resolvePath } from 'node:path'
|
|
47
|
+
|
|
48
|
+
export interface UploadsHandlerOptions {
|
|
49
|
+
/**
|
|
50
|
+
* URL prefix to match. Trailing slash required. Default `'/uploads/'`.
|
|
51
|
+
* Must match the `baseUrl` you passed to `localStorageProvider`.
|
|
52
|
+
*/
|
|
53
|
+
prefix?: string
|
|
54
|
+
/**
|
|
55
|
+
* Filesystem directory to serve. Default `<process.cwd()>/uploads`.
|
|
56
|
+
* Must match the `uploadDir` you passed to `localStorageProvider`.
|
|
57
|
+
*/
|
|
58
|
+
dir?: string
|
|
59
|
+
/**
|
|
60
|
+
* `Cache-Control` header. Default
|
|
61
|
+
* `'public, max-age=31536000, immutable'` — safe because the local
|
|
62
|
+
* provider UUID-prefixes filenames, so a given URL never points at
|
|
63
|
+
* different bytes across uploads.
|
|
64
|
+
*/
|
|
65
|
+
cacheControl?: string
|
|
66
|
+
/**
|
|
67
|
+
* Extra `extension → MIME type` entries merged on top of the built-in
|
|
68
|
+
* map. Use this to add formats the default map doesn't cover.
|
|
69
|
+
*/
|
|
70
|
+
mimeTypes?: Record<string, string>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DEFAULT_MIME: Record<string, string> = {
|
|
74
|
+
'.avif': 'image/avif',
|
|
75
|
+
'.webp': 'image/webp',
|
|
76
|
+
'.jpg': 'image/jpeg',
|
|
77
|
+
'.jpeg': 'image/jpeg',
|
|
78
|
+
'.png': 'image/png',
|
|
79
|
+
'.gif': 'image/gif',
|
|
80
|
+
'.svg': 'image/svg+xml',
|
|
81
|
+
'.mp4': 'video/mp4',
|
|
82
|
+
'.webm': 'video/webm',
|
|
83
|
+
'.pdf': 'application/pdf',
|
|
84
|
+
'.json': 'application/json',
|
|
85
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const DEFAULT_CACHE_CONTROL = 'public, max-age=31536000, immutable'
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build a `/uploads/*` handler with custom configuration. Returns a
|
|
92
|
+
* function suitable for use inside `createServerEntry({ fetch })` —
|
|
93
|
+
* resolves to a `Response` when the request matched and the file
|
|
94
|
+
* existed, and to `null` when the request should fall through to the
|
|
95
|
+
* framework handler.
|
|
96
|
+
*/
|
|
97
|
+
export function createUploadsHandler(
|
|
98
|
+
options: UploadsHandlerOptions = {}
|
|
99
|
+
): (request: Request) => Promise<Response | null> {
|
|
100
|
+
const prefix = options.prefix ?? '/uploads/'
|
|
101
|
+
const dir = resolvePath(options.dir ?? `${process.cwd()}/uploads`)
|
|
102
|
+
const cacheControl = options.cacheControl ?? DEFAULT_CACHE_CONTROL
|
|
103
|
+
const mime = options.mimeTypes ? { ...DEFAULT_MIME, ...options.mimeTypes } : DEFAULT_MIME
|
|
104
|
+
|
|
105
|
+
return async function serveUploadsHandler(request: Request): Promise<Response | null> {
|
|
106
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return null
|
|
107
|
+
|
|
108
|
+
const url = new URL(request.url)
|
|
109
|
+
if (!url.pathname.startsWith(prefix)) return null
|
|
110
|
+
|
|
111
|
+
let rel: string
|
|
112
|
+
try {
|
|
113
|
+
rel = decodeURIComponent(url.pathname.slice(prefix.length))
|
|
114
|
+
} catch {
|
|
115
|
+
return new Response('Bad Request', { status: 400 })
|
|
116
|
+
}
|
|
117
|
+
if (!rel) return null
|
|
118
|
+
|
|
119
|
+
// Path-traversal guard: `resolve` collapses `..` segments before we
|
|
120
|
+
// compare. The resolved absolute path must stay within `dir`.
|
|
121
|
+
const abs = resolvePath(dir, rel)
|
|
122
|
+
if (abs !== dir && !abs.startsWith(`${dir}/`)) {
|
|
123
|
+
return new Response('Forbidden', { status: 403 })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let info
|
|
127
|
+
try {
|
|
128
|
+
info = await stat(abs)
|
|
129
|
+
} catch {
|
|
130
|
+
return null // fall through to the framework handler / SPA 404
|
|
131
|
+
}
|
|
132
|
+
if (!info.isFile()) return null
|
|
133
|
+
|
|
134
|
+
const dot = abs.lastIndexOf('.')
|
|
135
|
+
const ext = dot >= 0 ? abs.slice(dot).toLowerCase() : ''
|
|
136
|
+
const type = mime[ext] || 'application/octet-stream'
|
|
137
|
+
|
|
138
|
+
const headers = new Headers({
|
|
139
|
+
'Content-Type': type,
|
|
140
|
+
'Content-Length': info.size.toString(),
|
|
141
|
+
'Last-Modified': new Date(info.mtimeMs).toUTCString(),
|
|
142
|
+
'Cache-Control': cacheControl,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
if (request.method === 'HEAD') return new Response(null, { headers })
|
|
146
|
+
const body = await readFile(abs)
|
|
147
|
+
return new Response(body, { headers })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Default-configured `/uploads/*` handler. Matches
|
|
153
|
+
* `localStorageProvider({ uploadDir: './uploads', baseUrl: '/uploads' })`
|
|
154
|
+
* — the shape produced by `@byline/cli` host scaffolding.
|
|
155
|
+
*
|
|
156
|
+
* Resolved at module-load time, which fixes `process.cwd()` to the
|
|
157
|
+
* server's startup directory.
|
|
158
|
+
*/
|
|
159
|
+
export const serveUploads = createUploadsHandler()
|