@vyriy/static 0.5.6 → 0.6.0
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 +72 -19
- package/cache.d.ts +10 -0
- package/cache.js +166 -0
- package/content.d.ts +2 -0
- package/content.js +40 -0
- package/file.d.ts +7 -0
- package/file.js +42 -0
- package/index.d.ts +0 -1
- package/index.js +0 -1
- package/options.d.ts +14 -0
- package/options.js +18 -0
- package/package.json +44 -14
- package/server.d.ts +6 -1
- package/server.js +91 -9
- package/types.d.ts +44 -8
- package/use-spa.d.ts +2 -2
- package/use-spa.js +98 -14
- package/use-static.d.ts +2 -2
- package/use-static.js +65 -121
- package/with-static.d.ts +2 -2
- package/with-static.js +49 -18
- package/with-spa.d.ts +0 -3
- package/with-spa.js +0 -44
package/README.md
CHANGED
|
@@ -15,9 +15,9 @@ Serve a static directory:
|
|
|
15
15
|
```bash
|
|
16
16
|
vyriy-static
|
|
17
17
|
vyriy-static dist
|
|
18
|
-
vyriy-static
|
|
19
|
-
vyriy-static public
|
|
18
|
+
vyriy-static public --cache static
|
|
20
19
|
vyriy-static --port 3000 dist
|
|
20
|
+
vyriy-static dist --spa --fallback index.html --cache static
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
When no directory is provided to the CLI, it tries `dist`, `build`, `public`, `out`, and then the current directory.
|
|
@@ -25,9 +25,16 @@ When no directory is provided to the CLI, it tries `dist`, `build`, `public`, `o
|
|
|
25
25
|
CLI flags:
|
|
26
26
|
|
|
27
27
|
- `--port <port>` or `-p <port>` sets the local server port.
|
|
28
|
+
- `--cache <preset>` sets `none`, `default`, `static`, or `immutable`.
|
|
29
|
+
- `--index <file>` sets the static directory index file.
|
|
30
|
+
- `--not-found <file>` sets the static `404` response file.
|
|
31
|
+
- `--spa` enables SPA fallback mode.
|
|
32
|
+
- `--fallback <file>` sets the SPA fallback file.
|
|
28
33
|
- `--help` or `-h` prints command help.
|
|
29
34
|
- `--version` or `-v` prints the package version.
|
|
30
35
|
|
|
36
|
+
CLI option priority is explicit CLI args, then `VYRIY_STATIC_*` env variables, then defaults. Outside production, CLI serving defaults to `cache: 'none'` when `--cache` is omitted.
|
|
37
|
+
|
|
31
38
|
## API
|
|
32
39
|
|
|
33
40
|
Install as a project dependency:
|
|
@@ -36,29 +43,75 @@ Install as a project dependency:
|
|
|
36
43
|
npm install @vyriy/static
|
|
37
44
|
```
|
|
38
45
|
|
|
39
|
-
Use handlers
|
|
46
|
+
Use direct handlers:
|
|
40
47
|
|
|
41
48
|
```ts
|
|
42
|
-
import {
|
|
43
|
-
|
|
49
|
+
import { useSpa, useStatic } from '@vyriy/static';
|
|
50
|
+
|
|
51
|
+
export const assets = useStatic('./public', {
|
|
52
|
+
cache: 'immutable',
|
|
53
|
+
index: false,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const site = useStatic('./dist', {
|
|
57
|
+
cache: 'static',
|
|
58
|
+
index: 'index.html',
|
|
59
|
+
notFound: '404.html',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const app = useSpa('./dist', {
|
|
63
|
+
cache: 'static',
|
|
64
|
+
fallback: 'index.html',
|
|
65
|
+
});
|
|
66
|
+
```
|
|
44
67
|
|
|
45
|
-
|
|
46
|
-
|
|
68
|
+
Use router helpers:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { createRouter } from '@vyriy/router';
|
|
72
|
+
import { withStatic } from '@vyriy/static';
|
|
47
73
|
|
|
48
74
|
export const router = withStatic(createRouter())
|
|
49
|
-
.get('/api', () => ({ body: JSON.stringify({ ok: true }) }))
|
|
50
|
-
.static('/dist', {
|
|
51
|
-
.
|
|
52
|
-
|
|
75
|
+
.get('/api/health', () => ({ body: JSON.stringify({ ok: true }) }))
|
|
76
|
+
.static('/assets', './dist/assets', { cache: 'immutable' })
|
|
77
|
+
.spa('/app', './dist', { cache: 'static' })
|
|
78
|
+
.fallbackSpa('./landing-dist', { cache: 'static' });
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`static` and `spa` mounts strip the route prefix before resolving files:
|
|
82
|
+
|
|
83
|
+
```txt
|
|
84
|
+
/assets/logo.svg -> ./dist/assets/logo.svg
|
|
85
|
+
/app/assets/main.js -> ./dist/assets/main.js
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Global fallbacks are explicit:
|
|
89
|
+
|
|
90
|
+
- `fallback(handler)` registers a custom unmatched-request handler.
|
|
91
|
+
- `fallbackStatic(directory, options?)` serves unmatched requests through static file behavior.
|
|
92
|
+
- `fallbackSpa(directory, options?)` serves unmatched requests through SPA fallback behavior.
|
|
93
|
+
|
|
94
|
+
Only one fallback can be registered on a wrapped router.
|
|
95
|
+
|
|
96
|
+
## Cache
|
|
97
|
+
|
|
98
|
+
Cache presets:
|
|
99
|
+
|
|
100
|
+
- `false` or `'none'` sends `Cache-Control: no-store`.
|
|
101
|
+
- `'default'` sends `public, max-age=3600` with validators.
|
|
102
|
+
- `'immutable'` sends `public, max-age=31536000, immutable` with validators.
|
|
103
|
+
- `'static'` caches assets long-term and revalidates HTML and metadata.
|
|
104
|
+
|
|
105
|
+
The `static` preset is designed for S3/CloudFront, Storybook, SPA builds, MFE assets, and static sites:
|
|
53
106
|
|
|
54
|
-
|
|
107
|
+
```txt
|
|
108
|
+
assets -> long immutable cache
|
|
109
|
+
html/json/xml/txt/yml -> no-cache + ETag + Last-Modified
|
|
110
|
+
SPA fallback index.html -> no-cache + ETag + Last-Modified
|
|
55
111
|
```
|
|
56
112
|
|
|
57
|
-
|
|
58
|
-
- `useSpa(options)` serves static files and falls back to the configured index file for missing `GET` and `HEAD` paths.
|
|
59
|
-
- `withStatic(router).static(path, options)` adds prefix-based static mounts.
|
|
60
|
-
- `withStatic(router, options).static(path)` keeps a shared default for static mounts.
|
|
61
|
-
- `withSpa(router, directoryOrOptions)` adds static-first SPA fallback behavior.
|
|
113
|
+
Defaults:
|
|
62
114
|
|
|
63
|
-
|
|
64
|
-
|
|
115
|
+
- `useStatic('./dist')` uses `index: 'index.html'`, `notFound: false`, and `cache: 'default'`.
|
|
116
|
+
- `useSpa('./dist')` uses `fallback: 'index.html'` and `cache: 'static'`.
|
|
117
|
+
- `staticServer()` tries `dist`, `build`, `public`, `out`, and then the current directory.
|
package/cache.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ApiEvent, ApiResult } from '@vyriy/handler';
|
|
2
|
+
import type { StaticCacheConfig, StaticCacheOptions, StaticCachePreset } from './types.js';
|
|
3
|
+
type ResolvedCacheOptions = Required<Pick<StaticCacheOptions, 'etag' | 'lastModified'>> & Pick<StaticCacheOptions, 'immutable' | 'maxAge' | 'staleIfError' | 'staleWhileRevalidate'> & {
|
|
4
|
+
readonly cacheControl: string;
|
|
5
|
+
};
|
|
6
|
+
export declare const resolveCacheOptions: (cache: StaticCacheConfig | undefined, filePath: string, defaultCache: StaticCachePreset) => ResolvedCacheOptions;
|
|
7
|
+
export declare const createEtag: (size: number, modifiedTime: Date) => string;
|
|
8
|
+
export declare const createCacheHeaders: (cache: ResolvedCacheOptions, size: number, modifiedTime: Date) => Record<string, string>;
|
|
9
|
+
export declare const maybeNotModified: (event: ApiEvent, headers: Record<string, string>) => ApiResult | undefined;
|
|
10
|
+
export {};
|
package/cache.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const ASSET_EXTENSIONS = new Set([
|
|
3
|
+
'.avif',
|
|
4
|
+
'.css',
|
|
5
|
+
'.gif',
|
|
6
|
+
'.ico',
|
|
7
|
+
'.jpeg',
|
|
8
|
+
'.jpg',
|
|
9
|
+
'.js',
|
|
10
|
+
'.map',
|
|
11
|
+
'.mjs',
|
|
12
|
+
'.png',
|
|
13
|
+
'.svg',
|
|
14
|
+
'.webp',
|
|
15
|
+
'.woff',
|
|
16
|
+
'.woff2',
|
|
17
|
+
]);
|
|
18
|
+
const extensionGroupPattern = /\.\{([^}]+)\}$/;
|
|
19
|
+
const toCacheOptions = (cache) => {
|
|
20
|
+
if (cache === false || cache === 'none') {
|
|
21
|
+
return {
|
|
22
|
+
etag: false,
|
|
23
|
+
lastModified: false,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (cache === 'immutable') {
|
|
27
|
+
return {
|
|
28
|
+
etag: true,
|
|
29
|
+
immutable: true,
|
|
30
|
+
lastModified: true,
|
|
31
|
+
maxAge: 31_536_000,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (cache === 'default' || cache === 'static' || cache === undefined) {
|
|
35
|
+
return {
|
|
36
|
+
etag: true,
|
|
37
|
+
lastModified: true,
|
|
38
|
+
maxAge: 3_600,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return cache;
|
|
42
|
+
};
|
|
43
|
+
const matchRule = (filePath, match) => {
|
|
44
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
45
|
+
const extensionGroup = extensionGroupPattern.exec(match);
|
|
46
|
+
if (extensionGroup) {
|
|
47
|
+
return extensionGroup[1].split(',').some((value) => extension === `.${value.trim().toLowerCase()}`);
|
|
48
|
+
}
|
|
49
|
+
if (match.startsWith('**/*.')) {
|
|
50
|
+
return extension === match.slice('**/*'.length).toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
return filePath === match || filePath.endsWith(match);
|
|
53
|
+
};
|
|
54
|
+
const resolveStaticPreset = (extension) => {
|
|
55
|
+
if (ASSET_EXTENSIONS.has(extension)) {
|
|
56
|
+
return {
|
|
57
|
+
etag: true,
|
|
58
|
+
immutable: true,
|
|
59
|
+
lastModified: true,
|
|
60
|
+
maxAge: 31_536_000,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
etag: true,
|
|
65
|
+
lastModified: true,
|
|
66
|
+
maxAge: 0,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
const resolveRule = (cache, filePath) => {
|
|
70
|
+
if (typeof cache !== 'object' || !('rules' in cache)) {
|
|
71
|
+
return cache;
|
|
72
|
+
}
|
|
73
|
+
const rule = cache.rules.find(({ match }) => {
|
|
74
|
+
const matches = typeof match === 'string'
|
|
75
|
+
? [
|
|
76
|
+
match,
|
|
77
|
+
]
|
|
78
|
+
: match;
|
|
79
|
+
return matches.some((item) => matchRule(filePath, item));
|
|
80
|
+
});
|
|
81
|
+
return rule?.cache ?? cache.fallback;
|
|
82
|
+
};
|
|
83
|
+
export const resolveCacheOptions = (cache, filePath, defaultCache) => {
|
|
84
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
85
|
+
const selectedCache = cache === 'static' ? resolveStaticPreset(extension) : resolveRule(cache ?? defaultCache, filePath);
|
|
86
|
+
const options = toCacheOptions(selectedCache);
|
|
87
|
+
const noStore = selectedCache === false || selectedCache === 'none';
|
|
88
|
+
if (noStore) {
|
|
89
|
+
return {
|
|
90
|
+
cacheControl: 'no-store',
|
|
91
|
+
etag: false,
|
|
92
|
+
lastModified: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const maxAge = options.maxAge ?? 3_600;
|
|
96
|
+
const directives = maxAge === 0
|
|
97
|
+
? [
|
|
98
|
+
'no-cache',
|
|
99
|
+
]
|
|
100
|
+
: [
|
|
101
|
+
'public',
|
|
102
|
+
`max-age=${maxAge}`,
|
|
103
|
+
];
|
|
104
|
+
if (options.immutable) {
|
|
105
|
+
directives.push('immutable');
|
|
106
|
+
}
|
|
107
|
+
if (options.staleWhileRevalidate !== undefined) {
|
|
108
|
+
directives.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
|
|
109
|
+
}
|
|
110
|
+
if (options.staleIfError !== undefined) {
|
|
111
|
+
directives.push(`stale-if-error=${options.staleIfError}`);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
cacheControl: directives.join(', '),
|
|
115
|
+
etag: options.etag ?? true,
|
|
116
|
+
immutable: options.immutable,
|
|
117
|
+
lastModified: options.lastModified ?? true,
|
|
118
|
+
maxAge,
|
|
119
|
+
staleIfError: options.staleIfError,
|
|
120
|
+
staleWhileRevalidate: options.staleWhileRevalidate,
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
export const createEtag = (size, modifiedTime) => `W/"${size.toString(16)}-${Math.trunc(modifiedTime.getTime()).toString(16)}"`;
|
|
124
|
+
export const createCacheHeaders = (cache, size, modifiedTime) => {
|
|
125
|
+
const headers = {
|
|
126
|
+
'cache-control': cache.cacheControl,
|
|
127
|
+
};
|
|
128
|
+
if (cache.etag) {
|
|
129
|
+
headers.etag = createEtag(size, modifiedTime);
|
|
130
|
+
}
|
|
131
|
+
if (cache.lastModified) {
|
|
132
|
+
headers['last-modified'] = modifiedTime.toUTCString();
|
|
133
|
+
}
|
|
134
|
+
return headers;
|
|
135
|
+
};
|
|
136
|
+
export const maybeNotModified = (event, headers) => {
|
|
137
|
+
const requestHeaders = event.headers ?? {};
|
|
138
|
+
const ifNoneMatch = requestHeaders['if-none-match'] ?? requestHeaders['If-None-Match'];
|
|
139
|
+
if (ifNoneMatch) {
|
|
140
|
+
if (headers.etag &&
|
|
141
|
+
ifNoneMatch
|
|
142
|
+
.split(',')
|
|
143
|
+
.map((value) => value.trim())
|
|
144
|
+
.includes(headers.etag)) {
|
|
145
|
+
return {
|
|
146
|
+
body: '',
|
|
147
|
+
headers,
|
|
148
|
+
statusCode: 304,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
const ifModifiedSince = requestHeaders['if-modified-since'] ?? requestHeaders['If-Modified-Since'];
|
|
154
|
+
if (ifModifiedSince && headers['last-modified']) {
|
|
155
|
+
const requestTime = Date.parse(ifModifiedSince);
|
|
156
|
+
const modifiedTime = Date.parse(headers['last-modified']);
|
|
157
|
+
if (!Number.isNaN(requestTime) && modifiedTime <= requestTime) {
|
|
158
|
+
return {
|
|
159
|
+
body: '',
|
|
160
|
+
headers,
|
|
161
|
+
statusCode: 304,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
};
|
package/content.d.ts
ADDED
package/content.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const CONTENT_TYPES = {
|
|
2
|
+
'.avif': 'image/avif',
|
|
3
|
+
'.css': 'text/css; charset=utf-8',
|
|
4
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
5
|
+
'.gif': 'image/gif',
|
|
6
|
+
'.html': 'text/html; charset=utf-8',
|
|
7
|
+
'.ico': 'image/x-icon',
|
|
8
|
+
'.jpeg': 'image/jpeg',
|
|
9
|
+
'.jpg': 'image/jpeg',
|
|
10
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
11
|
+
'.json': 'application/json; charset=utf-8',
|
|
12
|
+
'.map': 'application/json; charset=utf-8',
|
|
13
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
14
|
+
'.pdf': 'application/pdf',
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.svg': 'image/svg+xml; charset=utf-8',
|
|
17
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
18
|
+
'.webp': 'image/webp',
|
|
19
|
+
'.woff': 'font/woff',
|
|
20
|
+
'.woff2': 'font/woff2',
|
|
21
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
22
|
+
'.yaml': 'text/yaml; charset=utf-8',
|
|
23
|
+
'.yml': 'text/yaml; charset=utf-8',
|
|
24
|
+
};
|
|
25
|
+
const TEXT_TYPES = new Set([
|
|
26
|
+
'.css',
|
|
27
|
+
'.csv',
|
|
28
|
+
'.html',
|
|
29
|
+
'.js',
|
|
30
|
+
'.json',
|
|
31
|
+
'.map',
|
|
32
|
+
'.mjs',
|
|
33
|
+
'.svg',
|
|
34
|
+
'.txt',
|
|
35
|
+
'.xml',
|
|
36
|
+
'.yaml',
|
|
37
|
+
'.yml',
|
|
38
|
+
]);
|
|
39
|
+
export const getContentType = (extension) => CONTENT_TYPES[extension.toLowerCase()] ?? 'application/octet-stream';
|
|
40
|
+
export const isTextExtension = (extension) => TEXT_TYPES.has(extension.toLowerCase());
|
package/file.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const isInsideDirectory: (directory: string, candidate: string) => boolean;
|
|
2
|
+
export declare const resolveRoot: (directory: string) => Promise<string>;
|
|
3
|
+
export declare const resolveExistingFile: (root: string, requestPath: string, index: false | string) => Promise<{
|
|
4
|
+
filePath: string;
|
|
5
|
+
modifiedTime: Date;
|
|
6
|
+
size: number;
|
|
7
|
+
} | undefined>;
|
package/file.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const isInsideDirectory = (directory, candidate) => {
|
|
4
|
+
const relative = path.relative(directory, candidate);
|
|
5
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
6
|
+
};
|
|
7
|
+
export const resolveRoot = (directory) => realpath(directory);
|
|
8
|
+
export const resolveExistingFile = async (root, requestPath, index) => {
|
|
9
|
+
const requestedPath = requestPath.replace(/^\/+/, '') || index || '';
|
|
10
|
+
let candidate = path.resolve(root, requestedPath);
|
|
11
|
+
if (!isInsideDirectory(root, candidate)) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const candidateStat = await stat(candidate);
|
|
16
|
+
if (candidateStat.isDirectory()) {
|
|
17
|
+
if (!index) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
candidate = path.join(candidate, index);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
if (!isInsideDirectory(root, candidate)) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const realFilePath = await realpath(candidate);
|
|
30
|
+
if (!isInsideDirectory(root, realFilePath)) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const fileStat = await stat(realFilePath);
|
|
34
|
+
if (!fileStat.isFile()) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
filePath: realFilePath,
|
|
39
|
+
modifiedTime: fileStat.mtime,
|
|
40
|
+
size: fileStat.size,
|
|
41
|
+
};
|
|
42
|
+
};
|
package/index.d.ts
CHANGED
package/index.js
CHANGED
package/options.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { StaticCacheConfig, UseSpaOptions, UseStaticOptions } from './types.js';
|
|
2
|
+
export type NormalizedStaticOptions = UseStaticOptions & {
|
|
3
|
+
readonly cache: StaticCacheConfig;
|
|
4
|
+
readonly directory: string;
|
|
5
|
+
readonly index: false | string;
|
|
6
|
+
readonly notFound: false | string;
|
|
7
|
+
};
|
|
8
|
+
export type NormalizedSpaOptions = UseSpaOptions & {
|
|
9
|
+
readonly cache: StaticCacheConfig;
|
|
10
|
+
readonly directory: string;
|
|
11
|
+
readonly fallback: string;
|
|
12
|
+
};
|
|
13
|
+
export declare const normalizeStaticOptions: (directory?: string, options?: UseStaticOptions) => NormalizedStaticOptions;
|
|
14
|
+
export declare const normalizeSpaOptions: (directory?: string, options?: UseSpaOptions) => NormalizedSpaOptions;
|
package/options.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const DEFAULT_DIRECTORY = 'dist';
|
|
2
|
+
const DEFAULT_FALLBACK = 'index.html';
|
|
3
|
+
const DEFAULT_INDEX = 'index.html';
|
|
4
|
+
export const normalizeStaticOptions = (directory = DEFAULT_DIRECTORY, options = {}) => {
|
|
5
|
+
return {
|
|
6
|
+
cache: options.cache ?? 'default',
|
|
7
|
+
directory,
|
|
8
|
+
headers: options.headers,
|
|
9
|
+
index: options.index ?? DEFAULT_INDEX,
|
|
10
|
+
notFound: options.notFound ?? false,
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export const normalizeSpaOptions = (directory = DEFAULT_DIRECTORY, options = {}) => ({
|
|
14
|
+
cache: options.cache ?? 'static',
|
|
15
|
+
directory,
|
|
16
|
+
fallback: options.fallback ?? DEFAULT_FALLBACK,
|
|
17
|
+
headers: options.headers,
|
|
18
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vyriy/static",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Static file and SPA serving helpers for Vyriy servers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
"vyriy-static": "./bin/index.js"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@vyriy/handler": "0.
|
|
12
|
-
"@vyriy/router": "0.
|
|
13
|
-
"@vyriy/server": "0.
|
|
11
|
+
"@vyriy/handler": "0.6.0",
|
|
12
|
+
"@vyriy/router": "0.6.0",
|
|
13
|
+
"@vyriy/server": "0.6.0"
|
|
14
14
|
},
|
|
15
15
|
"agents": "./AGENTS.md",
|
|
16
16
|
"license": "MIT",
|
|
@@ -27,6 +27,36 @@
|
|
|
27
27
|
"import": "./index.js",
|
|
28
28
|
"default": "./index.js"
|
|
29
29
|
},
|
|
30
|
+
"./cache": {
|
|
31
|
+
"types": "./cache.d.ts",
|
|
32
|
+
"import": "./cache.js",
|
|
33
|
+
"default": "./cache.js"
|
|
34
|
+
},
|
|
35
|
+
"./cache.js": {
|
|
36
|
+
"types": "./cache.d.ts",
|
|
37
|
+
"import": "./cache.js",
|
|
38
|
+
"default": "./cache.js"
|
|
39
|
+
},
|
|
40
|
+
"./content": {
|
|
41
|
+
"types": "./content.d.ts",
|
|
42
|
+
"import": "./content.js",
|
|
43
|
+
"default": "./content.js"
|
|
44
|
+
},
|
|
45
|
+
"./content.js": {
|
|
46
|
+
"types": "./content.d.ts",
|
|
47
|
+
"import": "./content.js",
|
|
48
|
+
"default": "./content.js"
|
|
49
|
+
},
|
|
50
|
+
"./file": {
|
|
51
|
+
"types": "./file.d.ts",
|
|
52
|
+
"import": "./file.js",
|
|
53
|
+
"default": "./file.js"
|
|
54
|
+
},
|
|
55
|
+
"./file.js": {
|
|
56
|
+
"types": "./file.d.ts",
|
|
57
|
+
"import": "./file.js",
|
|
58
|
+
"default": "./file.js"
|
|
59
|
+
},
|
|
30
60
|
"./index": {
|
|
31
61
|
"types": "./index.d.ts",
|
|
32
62
|
"import": "./index.js",
|
|
@@ -37,6 +67,16 @@
|
|
|
37
67
|
"import": "./index.js",
|
|
38
68
|
"default": "./index.js"
|
|
39
69
|
},
|
|
70
|
+
"./options": {
|
|
71
|
+
"types": "./options.d.ts",
|
|
72
|
+
"import": "./options.js",
|
|
73
|
+
"default": "./options.js"
|
|
74
|
+
},
|
|
75
|
+
"./options.js": {
|
|
76
|
+
"types": "./options.d.ts",
|
|
77
|
+
"import": "./options.js",
|
|
78
|
+
"default": "./options.js"
|
|
79
|
+
},
|
|
40
80
|
"./server": {
|
|
41
81
|
"types": "./server.d.ts",
|
|
42
82
|
"import": "./server.js",
|
|
@@ -67,16 +107,6 @@
|
|
|
67
107
|
"import": "./use-static.js",
|
|
68
108
|
"default": "./use-static.js"
|
|
69
109
|
},
|
|
70
|
-
"./with-spa": {
|
|
71
|
-
"types": "./with-spa.d.ts",
|
|
72
|
-
"import": "./with-spa.js",
|
|
73
|
-
"default": "./with-spa.js"
|
|
74
|
-
},
|
|
75
|
-
"./with-spa.js": {
|
|
76
|
-
"types": "./with-spa.d.ts",
|
|
77
|
-
"import": "./with-spa.js",
|
|
78
|
-
"default": "./with-spa.js"
|
|
79
|
-
},
|
|
80
110
|
"./with-static": {
|
|
81
111
|
"types": "./with-static.d.ts",
|
|
82
112
|
"import": "./with-static.js",
|
package/server.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import type { StaticServer } from './types.js';
|
|
1
|
+
import type { StaticCachePreset, StaticServer } from './types.js';
|
|
2
2
|
export type StaticBinCommand = {
|
|
3
3
|
readonly type: 'help' | 'version';
|
|
4
4
|
} | {
|
|
5
|
+
readonly cache?: StaticCachePreset;
|
|
5
6
|
readonly type: 'serve';
|
|
6
7
|
readonly directory?: string;
|
|
8
|
+
readonly fallback?: string;
|
|
9
|
+
readonly index?: false | string;
|
|
10
|
+
readonly notFound?: false | string;
|
|
7
11
|
readonly port?: string;
|
|
12
|
+
readonly spa?: boolean;
|
|
8
13
|
};
|
|
9
14
|
export type RunStaticCli = (args?: readonly string[], command?: string, alias?: false | string) => Promise<void>;
|
|
10
15
|
export declare const staticVersion: string;
|
package/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { server } from '@vyriy/server';
|
|
3
3
|
import packageJson from './package.json' with { type: 'json' };
|
|
4
|
+
import { useSpa } from './use-spa.js';
|
|
4
5
|
import { useStatic } from './use-static.js';
|
|
5
6
|
const DIRECTORIES = [
|
|
6
7
|
'dist',
|
|
@@ -16,32 +17,85 @@ const getOptionValue = (args, name, alias) => {
|
|
|
16
17
|
const index = args.findIndex((arg) => arg === name || arg === alias);
|
|
17
18
|
return index >= 0 ? args[index + 1] : undefined;
|
|
18
19
|
};
|
|
20
|
+
const hasOption = (args, name) => args.includes(name);
|
|
19
21
|
const isOptionValue = (args, index) => {
|
|
20
22
|
const previous = args[index - 1];
|
|
21
|
-
return previous === '--
|
|
23
|
+
return (previous === '--cache' ||
|
|
24
|
+
previous === '--fallback' ||
|
|
25
|
+
previous === '--index' ||
|
|
26
|
+
previous === '--not-found' ||
|
|
27
|
+
previous === '--port' ||
|
|
28
|
+
previous === '-p');
|
|
22
29
|
};
|
|
23
30
|
const resolveDirectory = (directory) => directory ?? DIRECTORIES.find((candidate) => existsSync(candidate)) ?? '.';
|
|
24
31
|
const createStaticHandler = useStatic;
|
|
32
|
+
const createSpaHandler = useSpa;
|
|
33
|
+
const normalizeCachePreset = (value) => {
|
|
34
|
+
if (!value) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
if (value === 'false' || value === 'none') {
|
|
38
|
+
return 'none';
|
|
39
|
+
}
|
|
40
|
+
if (value === 'default' || value === 'immutable' || value === 'static') {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Unsupported static cache preset: ${value}`);
|
|
44
|
+
};
|
|
45
|
+
const getCliCacheDefault = () => (process.env.NODE_ENV === 'production' ? 'default' : 'none');
|
|
46
|
+
const withEnvDefaults = (command) => ({
|
|
47
|
+
cache: command.cache ?? normalizeCachePreset(process.env.VYRIY_STATIC_CACHE) ?? getCliCacheDefault(),
|
|
48
|
+
directory: command.directory,
|
|
49
|
+
fallback: command.fallback ?? process.env.VYRIY_STATIC_FALLBACK,
|
|
50
|
+
index: command.index ?? process.env.VYRIY_STATIC_INDEX,
|
|
51
|
+
notFound: command.notFound ?? process.env.VYRIY_STATIC_NOT_FOUND,
|
|
52
|
+
spa: command.spa,
|
|
53
|
+
});
|
|
25
54
|
export const staticVersion = packageJson.version;
|
|
26
55
|
export const createStaticHelpText = (command = 'vyriy-static', alias = 'vs') => {
|
|
27
56
|
const aliasText = alias ? ` ${alias} [directory] Alias for ${command}\n` : '';
|
|
28
|
-
const aliasExampleText = alias ? `\n ${alias}
|
|
57
|
+
const aliasExampleText = alias ? `\n ${alias} .\n ${alias} --spa` : '';
|
|
29
58
|
return `Vyriy Static Server
|
|
30
59
|
|
|
31
60
|
Usage:
|
|
32
61
|
${command} [directory] Serve a static directory (defaults to dist when it exists)
|
|
62
|
+
${command} [directory] --spa Serve a static directory in SPA fallback mode
|
|
33
63
|
${command} --port 3000 dist Serve a directory on a specific port
|
|
34
64
|
${aliasText}\
|
|
65
|
+
${command} --spa Serve as an SPA with index fallback
|
|
66
|
+
${command} --cache static Set cache preset: none, default, static, immutable
|
|
35
67
|
${command} --help, -h Show help
|
|
36
68
|
${command} --version, -v Show version
|
|
37
69
|
|
|
38
70
|
Options:
|
|
39
|
-
-p, --port <port>
|
|
71
|
+
-p, --port <port> HTTP port. Default: PORT env or 3000
|
|
72
|
+
--cache <preset> Cache preset: none, default, static, immutable
|
|
73
|
+
--index <file> Static directory index file. Default: index.html
|
|
74
|
+
--not-found <file> Static 404 response file. Default: disabled
|
|
75
|
+
--spa Enable SPA fallback mode. Default: false
|
|
76
|
+
--fallback <file> SPA fallback file. Default: index.html
|
|
77
|
+
|
|
78
|
+
Defaults:
|
|
79
|
+
directory First existing: dist, build, public, out, then .
|
|
80
|
+
port PORT env or 3000
|
|
81
|
+
cache none when NODE_ENV is not production; default in production
|
|
82
|
+
index index.html
|
|
83
|
+
not-found disabled unless --not-found is provided
|
|
84
|
+
spa false
|
|
85
|
+
fallback index.html
|
|
86
|
+
|
|
87
|
+
Cache presets:
|
|
88
|
+
none Cache-Control: no-store
|
|
89
|
+
default public, max-age=3600 with validators
|
|
90
|
+
static immutable assets, revalidated HTML and metadata
|
|
91
|
+
immutable public, max-age=31536000, immutable
|
|
40
92
|
|
|
41
93
|
Examples:
|
|
42
94
|
${command}
|
|
43
95
|
${command} public
|
|
44
|
-
${command} --port 3000 dist
|
|
96
|
+
${command} --port 3000 dist
|
|
97
|
+
${command} dist --cache static
|
|
98
|
+
${command} dist --spa --fallback index.html --cache static${aliasExampleText}`;
|
|
45
99
|
};
|
|
46
100
|
export const parseStaticBinArgs = (args) => {
|
|
47
101
|
if (args.includes('--help') || args.includes('-h')) {
|
|
@@ -50,11 +104,33 @@ export const parseStaticBinArgs = (args) => {
|
|
|
50
104
|
if (args.includes('--version') || args.includes('-v')) {
|
|
51
105
|
return { type: 'version' };
|
|
52
106
|
}
|
|
53
|
-
|
|
107
|
+
const cache = normalizeCachePreset(getOptionValue(args, '--cache', '--cache'));
|
|
108
|
+
const directory = args.find((arg, index) => !arg.startsWith('-') && !isOptionValue(args, index));
|
|
109
|
+
const fallback = getOptionValue(args, '--fallback', '--fallback');
|
|
110
|
+
const index = getOptionValue(args, '--index', '--index');
|
|
111
|
+
const notFound = getOptionValue(args, '--not-found', '--not-found');
|
|
112
|
+
const port = getOptionValue(args, '--port', '-p');
|
|
113
|
+
const command = {
|
|
54
114
|
type: 'serve',
|
|
55
|
-
directory
|
|
56
|
-
port
|
|
115
|
+
directory,
|
|
116
|
+
port,
|
|
57
117
|
};
|
|
118
|
+
if (cache !== undefined) {
|
|
119
|
+
command.cache = cache;
|
|
120
|
+
}
|
|
121
|
+
if (fallback !== undefined) {
|
|
122
|
+
command.fallback = fallback;
|
|
123
|
+
}
|
|
124
|
+
if (index !== undefined) {
|
|
125
|
+
command.index = index;
|
|
126
|
+
}
|
|
127
|
+
if (notFound !== undefined) {
|
|
128
|
+
command.notFound = notFound;
|
|
129
|
+
}
|
|
130
|
+
if (hasOption(args, '--spa')) {
|
|
131
|
+
command.spa = true;
|
|
132
|
+
}
|
|
133
|
+
return command;
|
|
58
134
|
};
|
|
59
135
|
export const runStaticCli = async (args = [], command = 'vyriy-static', alias = 'vs') => {
|
|
60
136
|
const parsed = parseStaticBinArgs(args);
|
|
@@ -71,11 +147,17 @@ export const runStaticCli = async (args = [], command = 'vyriy-static', alias =
|
|
|
71
147
|
if (parsed.port) {
|
|
72
148
|
process.env.PORT = parsed.port;
|
|
73
149
|
}
|
|
74
|
-
process.exitCode = await staticServer(
|
|
150
|
+
process.exitCode = await staticServer(withEnvDefaults(parsed));
|
|
75
151
|
break;
|
|
76
152
|
}
|
|
77
153
|
};
|
|
78
154
|
export const staticServer = async (options = {}) => {
|
|
79
|
-
|
|
155
|
+
const handlerOptions = {
|
|
156
|
+
...options,
|
|
157
|
+
directory: resolveDirectory(options.directory),
|
|
158
|
+
};
|
|
159
|
+
await Promise.resolve(server(options.spa
|
|
160
|
+
? createSpaHandler(handlerOptions.directory, handlerOptions)
|
|
161
|
+
: createStaticHandler(handlerOptions.directory, handlerOptions)));
|
|
80
162
|
return 0;
|
|
81
163
|
};
|
package/types.d.ts
CHANGED
|
@@ -1,15 +1,51 @@
|
|
|
1
1
|
import type { ApiEvent, ApiResult, Handler } from '@vyriy/handler';
|
|
2
|
-
import type { RouterApi } from '@vyriy/router';
|
|
3
|
-
export type
|
|
2
|
+
import type { Handler as RouterHandler, RouterApi } from '@vyriy/router';
|
|
3
|
+
export type StaticCachePreset = false | 'none' | 'default' | 'static' | 'immutable';
|
|
4
|
+
export type StaticCacheOptions = {
|
|
5
|
+
readonly etag?: boolean;
|
|
6
|
+
readonly immutable?: boolean;
|
|
7
|
+
readonly lastModified?: boolean;
|
|
8
|
+
readonly maxAge?: number;
|
|
9
|
+
readonly staleIfError?: number;
|
|
10
|
+
readonly staleWhileRevalidate?: number;
|
|
11
|
+
};
|
|
12
|
+
export type StaticCacheRule = {
|
|
13
|
+
readonly cache: StaticCachePreset | StaticCacheOptions;
|
|
14
|
+
readonly match: string | readonly string[];
|
|
15
|
+
};
|
|
16
|
+
export type StaticCacheConfig = StaticCachePreset | StaticCacheOptions | {
|
|
17
|
+
readonly fallback?: StaticCachePreset | StaticCacheOptions;
|
|
18
|
+
readonly rules: readonly StaticCacheRule[];
|
|
19
|
+
};
|
|
20
|
+
export type StaticHeaderContext = {
|
|
21
|
+
readonly filePath: string;
|
|
22
|
+
readonly requestPath: string;
|
|
23
|
+
readonly statusCode: number;
|
|
24
|
+
};
|
|
25
|
+
export type StaticBaseOptions = {
|
|
26
|
+
readonly cache?: StaticCacheConfig;
|
|
27
|
+
readonly headers?: HeadersInit | ((context: StaticHeaderContext) => HeadersInit);
|
|
28
|
+
};
|
|
29
|
+
export type UseStaticOptions = StaticBaseOptions & {
|
|
30
|
+
readonly index?: false | string;
|
|
31
|
+
readonly notFound?: false | string;
|
|
32
|
+
};
|
|
33
|
+
export type UseSpaOptions = StaticBaseOptions & {
|
|
34
|
+
readonly fallback?: string;
|
|
35
|
+
};
|
|
36
|
+
export type StaticServerOptions = (UseStaticOptions | UseSpaOptions) & {
|
|
4
37
|
readonly directory?: string;
|
|
5
|
-
readonly
|
|
6
|
-
readonly error?: string;
|
|
38
|
+
readonly spa?: boolean;
|
|
7
39
|
};
|
|
8
|
-
export type StaticMountOptions = StaticOptions | string;
|
|
9
40
|
export type StaticHandler = Handler<ApiEvent, ApiResult>;
|
|
10
|
-
export type StaticServer = (options?:
|
|
41
|
+
export type StaticServer = (options?: StaticServerOptions) => Promise<number>;
|
|
11
42
|
export type StaticRouterApi = Omit<RouterApi, 'delete' | 'fallback' | 'get' | 'patch' | 'post' | 'put'> & {
|
|
12
|
-
static(path: string, options?:
|
|
43
|
+
static(path: string, directory: string, options?: UseStaticOptions): StaticRouterApi;
|
|
44
|
+
spa(path: string, directory: string, options?: UseSpaOptions): StaticRouterApi;
|
|
45
|
+
fallbackStatic(directory: string, options?: UseStaticOptions): StaticRouterApi;
|
|
46
|
+
fallbackSpa(directory: string, options?: UseSpaOptions): StaticRouterApi;
|
|
47
|
+
} & {
|
|
48
|
+
[Method in Extract<keyof RouterApi, 'delete' | 'get' | 'patch' | 'post' | 'put'>]: (...args: Parameters<RouterApi[Method]>) => StaticRouterApi;
|
|
13
49
|
} & {
|
|
14
|
-
|
|
50
|
+
fallback(handler: RouterHandler): StaticRouterApi;
|
|
15
51
|
};
|
package/use-spa.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type { StaticHandler,
|
|
2
|
-
export declare const useSpa: (options?:
|
|
1
|
+
import type { StaticHandler, UseSpaOptions } from './types.js';
|
|
2
|
+
export declare const useSpa: (directory?: string, options?: UseSpaOptions) => StaticHandler;
|
package/use-spa.js
CHANGED
|
@@ -1,17 +1,101 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createCacheHeaders, maybeNotModified, resolveCacheOptions } from './cache.js';
|
|
4
|
+
import { getContentType, isTextExtension } from './content.js';
|
|
5
|
+
import { resolveExistingFile, resolveRoot } from './file.js';
|
|
6
|
+
import { normalizeSpaOptions } from './options.js';
|
|
7
|
+
const NOT_FOUND_BODY = JSON.stringify({ message: 'Not Found' });
|
|
8
|
+
const METHOD_NOT_ALLOWED_BODY = JSON.stringify({ message: 'Method Not Allowed' });
|
|
9
|
+
const isSpaMethod = (method) => method === 'GET' || method === 'HEAD';
|
|
10
|
+
const toHeadersObject = (headers) => {
|
|
11
|
+
if (!headers) {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
if (headers instanceof Headers) {
|
|
15
|
+
return Object.fromEntries(headers.entries());
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(headers)) {
|
|
18
|
+
return Object.fromEntries(headers);
|
|
19
|
+
}
|
|
20
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, String(value)]));
|
|
21
|
+
};
|
|
22
|
+
const notFound = () => ({
|
|
23
|
+
statusCode: 404,
|
|
24
|
+
body: NOT_FOUND_BODY,
|
|
25
|
+
headers: {
|
|
26
|
+
'content-type': 'application/json; charset=utf-8',
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const methodNotAllowed = () => ({
|
|
30
|
+
statusCode: 405,
|
|
31
|
+
body: METHOD_NOT_ALLOWED_BODY,
|
|
32
|
+
headers: {
|
|
33
|
+
allow: 'GET, HEAD',
|
|
34
|
+
'content-type': 'application/json; charset=utf-8',
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const createFileResult = async (event, requestPath, file, options) => {
|
|
38
|
+
const extension = path.extname(file.filePath);
|
|
39
|
+
const isText = isTextExtension(extension);
|
|
40
|
+
const cache = resolveCacheOptions(options.cache, file.filePath, 'static');
|
|
41
|
+
const headers = {
|
|
42
|
+
'content-length': String(file.size),
|
|
43
|
+
'content-type': getContentType(extension),
|
|
44
|
+
...createCacheHeaders(cache, file.size, file.modifiedTime),
|
|
45
|
+
...toHeadersObject(typeof options.headers === 'function'
|
|
46
|
+
? options.headers({
|
|
47
|
+
filePath: file.filePath,
|
|
48
|
+
requestPath,
|
|
49
|
+
statusCode: 200,
|
|
50
|
+
})
|
|
51
|
+
: options.headers),
|
|
52
|
+
};
|
|
53
|
+
const notModified = maybeNotModified(event, headers);
|
|
54
|
+
if (notModified) {
|
|
55
|
+
return notModified;
|
|
56
|
+
}
|
|
57
|
+
if (event.httpMethod === 'HEAD') {
|
|
58
|
+
return {
|
|
59
|
+
statusCode: 200,
|
|
60
|
+
body: '',
|
|
61
|
+
headers,
|
|
62
|
+
isBase64Encoded: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const content = await readFile(file.filePath);
|
|
66
|
+
return {
|
|
67
|
+
statusCode: 200,
|
|
68
|
+
body: content.toString(isText ? 'utf8' : 'base64'),
|
|
69
|
+
headers,
|
|
70
|
+
isBase64Encoded: !isText,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
export const useSpa = (directory = 'dist', options = {}) => {
|
|
74
|
+
const normalizedOptions = normalizeSpaOptions(directory, options);
|
|
75
|
+
let root;
|
|
76
|
+
const getRoot = () => (root ??= resolveRoot(normalizedOptions.directory));
|
|
77
|
+
const readSpaFile = async (event, requestPath) => {
|
|
78
|
+
const staticRoot = await getRoot();
|
|
79
|
+
const file = await resolveExistingFile(staticRoot, requestPath, 'index.html');
|
|
80
|
+
return file ? createFileResult(event, requestPath, file, normalizedOptions) : undefined;
|
|
81
|
+
};
|
|
82
|
+
return async (event) => {
|
|
83
|
+
if (!isSpaMethod(event.httpMethod)) {
|
|
84
|
+
return methodNotAllowed();
|
|
85
|
+
}
|
|
86
|
+
let decodedPath;
|
|
87
|
+
try {
|
|
88
|
+
decodedPath = decodeURIComponent(event.path);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return notFound();
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const realFile = await readSpaFile(event, decodedPath);
|
|
95
|
+
return realFile ?? (await readSpaFile(event, `/${normalizedOptions.fallback}`)) ?? notFound();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return notFound();
|
|
11
99
|
}
|
|
12
|
-
return handler({
|
|
13
|
-
...event,
|
|
14
|
-
path: `/${index}`,
|
|
15
|
-
}, context);
|
|
16
100
|
};
|
|
17
101
|
};
|
package/use-static.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type { StaticHandler,
|
|
2
|
-
export declare const useStatic: (options?:
|
|
1
|
+
import type { StaticHandler, UseStaticOptions } from './types.js';
|
|
2
|
+
export declare const useStatic: (directory?: string, options?: UseStaticOptions) => StaticHandler;
|
package/use-static.js
CHANGED
|
@@ -1,45 +1,28 @@
|
|
|
1
|
-
import { readFile
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { createCacheHeaders, maybeNotModified, resolveCacheOptions } from './cache.js';
|
|
4
|
+
import { getContentType, isTextExtension } from './content.js';
|
|
5
|
+
import { resolveExistingFile, resolveRoot } from './file.js';
|
|
6
|
+
import { normalizeStaticOptions } from './options.js';
|
|
6
7
|
const NOT_FOUND_BODY = JSON.stringify({ message: 'Not Found' });
|
|
7
8
|
const METHOD_NOT_ALLOWED_BODY = JSON.stringify({ message: 'Method Not Allowed' });
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'.pdf': 'application/pdf',
|
|
21
|
-
'.png': 'image/png',
|
|
22
|
-
'.svg': 'image/svg+xml; charset=utf-8',
|
|
23
|
-
'.txt': 'text/plain; charset=utf-8',
|
|
24
|
-
'.webp': 'image/webp',
|
|
25
|
-
'.woff': 'font/woff',
|
|
26
|
-
'.woff2': 'font/woff2',
|
|
27
|
-
'.xml': 'application/xml; charset=utf-8',
|
|
9
|
+
const isStaticMethod = (method) => method === 'GET' || method === 'HEAD';
|
|
10
|
+
const toHeadersObject = (headers) => {
|
|
11
|
+
if (!headers) {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
if (headers instanceof Headers) {
|
|
15
|
+
return Object.fromEntries(headers.entries());
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(headers)) {
|
|
18
|
+
return Object.fromEntries(headers);
|
|
19
|
+
}
|
|
20
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, String(value)]));
|
|
28
21
|
};
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
'
|
|
32
|
-
|
|
33
|
-
'.js',
|
|
34
|
-
'.json',
|
|
35
|
-
'.map',
|
|
36
|
-
'.mjs',
|
|
37
|
-
'.svg',
|
|
38
|
-
'.txt',
|
|
39
|
-
'.xml',
|
|
40
|
-
]);
|
|
41
|
-
const getContentType = (extension) => CONTENT_TYPES[extension.toLowerCase()] ?? 'application/octet-stream';
|
|
42
|
-
const isTextExtension = (extension) => TEXT_TYPES.has(extension.toLowerCase());
|
|
22
|
+
const mergeCustomHeaders = (headers, customHeaders, context) => ({
|
|
23
|
+
...headers,
|
|
24
|
+
...toHeadersObject(typeof customHeaders === 'function' ? customHeaders(context) : customHeaders),
|
|
25
|
+
});
|
|
43
26
|
const notFound = (body = NOT_FOUND_BODY, headers) => ({
|
|
44
27
|
statusCode: 404,
|
|
45
28
|
body,
|
|
@@ -55,116 +38,77 @@ const methodNotAllowed = () => ({
|
|
|
55
38
|
'content-type': 'application/json; charset=utf-8',
|
|
56
39
|
},
|
|
57
40
|
});
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const relative = path.relative(directory, candidate);
|
|
61
|
-
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
62
|
-
};
|
|
63
|
-
const normalizeOptions = ({ directory = DEFAULT_DIRECTORY, error = DEFAULT_ERROR, index = DEFAULT_INDEX, }) => ({
|
|
64
|
-
directory,
|
|
65
|
-
error,
|
|
66
|
-
index,
|
|
67
|
-
});
|
|
68
|
-
const createFileResult = async (realFilePath, fileSize, method) => {
|
|
69
|
-
const extension = path.extname(realFilePath);
|
|
41
|
+
const createFileResult = async (event, requestPath, file, options, statusCode = 200) => {
|
|
42
|
+
const extension = path.extname(file.filePath);
|
|
70
43
|
const isText = isTextExtension(extension);
|
|
71
|
-
|
|
44
|
+
const cache = resolveCacheOptions(options.cache, file.filePath, 'default');
|
|
45
|
+
const headers = mergeCustomHeaders({
|
|
46
|
+
'content-length': String(file.size),
|
|
47
|
+
'content-type': getContentType(extension),
|
|
48
|
+
...createCacheHeaders(cache, file.size, file.modifiedTime),
|
|
49
|
+
}, options.headers, {
|
|
50
|
+
filePath: file.filePath,
|
|
51
|
+
requestPath,
|
|
52
|
+
statusCode,
|
|
53
|
+
});
|
|
54
|
+
const notModified = statusCode === 200 ? maybeNotModified(event, headers) : undefined;
|
|
55
|
+
if (notModified) {
|
|
56
|
+
return notModified;
|
|
57
|
+
}
|
|
58
|
+
if (event.httpMethod === 'HEAD') {
|
|
72
59
|
return {
|
|
73
|
-
statusCode
|
|
60
|
+
statusCode,
|
|
74
61
|
body: '',
|
|
75
|
-
headers
|
|
76
|
-
'content-length': String(fileSize),
|
|
77
|
-
'content-type': getContentType(extension),
|
|
78
|
-
},
|
|
62
|
+
headers,
|
|
79
63
|
isBase64Encoded: false,
|
|
80
64
|
};
|
|
81
65
|
}
|
|
82
|
-
const content = await readFile(
|
|
66
|
+
const content = await readFile(file.filePath);
|
|
83
67
|
return {
|
|
84
|
-
statusCode
|
|
68
|
+
statusCode,
|
|
85
69
|
body: content.toString(isText ? 'utf8' : 'base64'),
|
|
86
|
-
headers
|
|
87
|
-
'content-length': String(fileSize),
|
|
88
|
-
'content-type': getContentType(extension),
|
|
89
|
-
},
|
|
70
|
+
headers,
|
|
90
71
|
isBase64Encoded: !isText,
|
|
91
72
|
};
|
|
92
73
|
};
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
const realFilePath = await realpath(filePath);
|
|
98
|
-
if (!isInsideDirectory(root, realFilePath)) {
|
|
99
|
-
return undefined;
|
|
100
|
-
}
|
|
101
|
-
const fileStat = await stat(realFilePath);
|
|
102
|
-
if (!fileStat.isFile()) {
|
|
103
|
-
return undefined;
|
|
104
|
-
}
|
|
105
|
-
return createFileResult(realFilePath, fileStat.size, method);
|
|
106
|
-
};
|
|
107
|
-
const readErrorFile = async (root, error, method) => {
|
|
108
|
-
try {
|
|
109
|
-
const result = await readStaticFile(root, path.resolve(root, error), method);
|
|
110
|
-
if (!result) {
|
|
111
|
-
return notFound();
|
|
112
|
-
}
|
|
113
|
-
return {
|
|
114
|
-
...result,
|
|
115
|
-
statusCode: 404,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
return notFound();
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
export const useStatic = (options = {}) => {
|
|
123
|
-
const { directory, error, index } = normalizeOptions(options);
|
|
74
|
+
export const useStatic = (directory = 'dist', options = {}) => {
|
|
75
|
+
const normalizedOptions = normalizeStaticOptions(directory, options);
|
|
124
76
|
let root;
|
|
125
|
-
const getRoot = () => (root ??=
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
77
|
+
const getRoot = () => (root ??= resolveRoot(normalizedOptions.directory));
|
|
78
|
+
const readFileResult = async (event, requestPath, statusCode = 200) => {
|
|
79
|
+
const staticRoot = await getRoot();
|
|
80
|
+
const file = await resolveExistingFile(staticRoot, requestPath, normalizedOptions.index);
|
|
81
|
+
return file ? createFileResult(event, requestPath, file, normalizedOptions, statusCode) : undefined;
|
|
82
|
+
};
|
|
83
|
+
const readNotFoundFile = async (event) => {
|
|
84
|
+
if (!normalizedOptions.notFound) {
|
|
135
85
|
return notFound();
|
|
136
86
|
}
|
|
137
|
-
let staticRoot;
|
|
138
87
|
try {
|
|
139
|
-
|
|
88
|
+
const result = await readFileResult(event, `/${normalizedOptions.notFound}`, 404);
|
|
89
|
+
return result ?? notFound();
|
|
140
90
|
}
|
|
141
91
|
catch {
|
|
142
92
|
return notFound();
|
|
143
93
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (!
|
|
147
|
-
return
|
|
94
|
+
};
|
|
95
|
+
return async (event) => {
|
|
96
|
+
if (!isStaticMethod(event.httpMethod)) {
|
|
97
|
+
return methodNotAllowed();
|
|
148
98
|
}
|
|
149
|
-
let
|
|
99
|
+
let decodedPath;
|
|
150
100
|
try {
|
|
151
|
-
|
|
152
|
-
if (fileStat.isDirectory()) {
|
|
153
|
-
filePath = path.join(filePath, index);
|
|
154
|
-
}
|
|
101
|
+
decodedPath = decodeURIComponent(event.path);
|
|
155
102
|
}
|
|
156
103
|
catch {
|
|
157
|
-
return
|
|
158
|
-
}
|
|
159
|
-
if (!isInsideDirectory(staticRoot, filePath)) {
|
|
160
|
-
return readErrorFile(staticRoot, error, event.httpMethod);
|
|
104
|
+
return notFound();
|
|
161
105
|
}
|
|
162
106
|
try {
|
|
163
|
-
const result = await
|
|
164
|
-
return result ??
|
|
107
|
+
const result = await readFileResult(event, decodedPath);
|
|
108
|
+
return result ?? readNotFoundFile(event);
|
|
165
109
|
}
|
|
166
110
|
catch {
|
|
167
|
-
return
|
|
111
|
+
return readNotFoundFile(event);
|
|
168
112
|
}
|
|
169
113
|
};
|
|
170
114
|
};
|
package/with-static.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { StaticRouterApi } from './types.js';
|
|
2
2
|
import type { RouterApi } from '@vyriy/router';
|
|
3
|
-
export declare const withStatic: (router: RouterApi
|
|
3
|
+
export declare const withStatic: (router: RouterApi) => StaticRouterApi;
|
package/with-static.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import { useSpa } from './use-spa.js';
|
|
1
2
|
import { useStatic } from './use-static.js';
|
|
2
|
-
const
|
|
3
|
-
|
|
3
|
+
const createSpaHandler = useSpa;
|
|
4
|
+
const createStaticHandler = useStatic;
|
|
5
|
+
const normalizeMount = (route) => {
|
|
6
|
+
const mount = route.startsWith('/') ? route : `/${route}`;
|
|
4
7
|
let end = mount.length;
|
|
5
8
|
while (end > 1 && mount[end - 1] === '/') {
|
|
6
9
|
end--;
|
|
7
10
|
}
|
|
8
11
|
return mount.slice(0, end);
|
|
9
12
|
};
|
|
10
|
-
const
|
|
13
|
+
const toMountedPath = (mount, requestPath) => {
|
|
11
14
|
if (mount === '/') {
|
|
12
15
|
return requestPath;
|
|
13
16
|
}
|
|
@@ -20,19 +23,43 @@ const toStaticPath = (mount, requestPath) => {
|
|
|
20
23
|
return undefined;
|
|
21
24
|
};
|
|
22
25
|
const isStaticMethod = (method) => method === 'GET' || method === 'HEAD';
|
|
23
|
-
const
|
|
24
|
-
|
|
26
|
+
const createFallbackGuard = () => {
|
|
27
|
+
let hasFallback = false;
|
|
28
|
+
return () => {
|
|
29
|
+
if (hasFallback) {
|
|
30
|
+
throw new Error('Router fallback already exists!');
|
|
31
|
+
}
|
|
32
|
+
hasFallback = true;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
export const withStatic = (router) => {
|
|
25
36
|
const mounts = [];
|
|
26
|
-
|
|
37
|
+
let fallbackHandler;
|
|
38
|
+
const registerFallback = createFallbackGuard();
|
|
39
|
+
const addMount = (mount) => {
|
|
40
|
+
mounts.push(mount);
|
|
41
|
+
mounts.sort((left, right) => right.route.length - left.route.length);
|
|
42
|
+
};
|
|
27
43
|
const api = {
|
|
28
44
|
delete(path, handler) {
|
|
29
45
|
router.delete(path, handler);
|
|
30
46
|
return api;
|
|
31
47
|
},
|
|
32
48
|
fallback(handler) {
|
|
49
|
+
registerFallback();
|
|
33
50
|
router.fallback(handler);
|
|
34
51
|
return api;
|
|
35
52
|
},
|
|
53
|
+
fallbackSpa(directory, fallbackOptions) {
|
|
54
|
+
registerFallback();
|
|
55
|
+
fallbackHandler = createSpaHandler(directory, fallbackOptions);
|
|
56
|
+
return api;
|
|
57
|
+
},
|
|
58
|
+
fallbackStatic(directory, fallbackOptions) {
|
|
59
|
+
registerFallback();
|
|
60
|
+
fallbackHandler = createStaticHandler(directory, fallbackOptions);
|
|
61
|
+
return api;
|
|
62
|
+
},
|
|
36
63
|
get(path, handler) {
|
|
37
64
|
router.get(path, handler);
|
|
38
65
|
return api;
|
|
@@ -58,25 +85,29 @@ export const withStatic = (router, options) => {
|
|
|
58
85
|
return result;
|
|
59
86
|
}
|
|
60
87
|
for (const mount of mounts) {
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
88
|
+
const mountedPath = toMountedPath(mount.route, event.path);
|
|
89
|
+
if (mountedPath === undefined) {
|
|
63
90
|
continue;
|
|
64
91
|
}
|
|
65
|
-
|
|
92
|
+
return mount.handler({
|
|
66
93
|
...event,
|
|
67
|
-
path:
|
|
94
|
+
path: mountedPath,
|
|
68
95
|
}, {});
|
|
69
|
-
return staticResult;
|
|
70
96
|
}
|
|
71
|
-
return result;
|
|
97
|
+
return fallbackHandler ? fallbackHandler(event, {}) : result;
|
|
98
|
+
},
|
|
99
|
+
spa(route, directory, mountOptions) {
|
|
100
|
+
addMount({
|
|
101
|
+
handler: createSpaHandler(directory, mountOptions),
|
|
102
|
+
route: normalizeMount(route),
|
|
103
|
+
});
|
|
104
|
+
return api;
|
|
72
105
|
},
|
|
73
|
-
static(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
path: normalizeMount(path),
|
|
106
|
+
static(route, directory, mountOptions) {
|
|
107
|
+
addMount({
|
|
108
|
+
handler: createStaticHandler(directory, mountOptions),
|
|
109
|
+
route: normalizeMount(route),
|
|
78
110
|
});
|
|
79
|
-
mounts.sort((left, right) => right.path.length - left.path.length);
|
|
80
111
|
return api;
|
|
81
112
|
},
|
|
82
113
|
};
|
package/with-spa.d.ts
DELETED
package/with-spa.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { useSpa } from './use-spa.js';
|
|
2
|
-
const createSpaHandler = useSpa;
|
|
3
|
-
const isStaticMethod = (method) => method === 'GET' || method === 'HEAD';
|
|
4
|
-
export const withSpa = (router, directoryOrOptions = {}) => {
|
|
5
|
-
const options = typeof directoryOrOptions === 'string' ? { directory: directoryOrOptions } : directoryOrOptions;
|
|
6
|
-
const spaHandler = createSpaHandler(options);
|
|
7
|
-
const api = {
|
|
8
|
-
delete(path, handler) {
|
|
9
|
-
router.delete(path, handler);
|
|
10
|
-
return api;
|
|
11
|
-
},
|
|
12
|
-
fallback(handler) {
|
|
13
|
-
router.fallback(handler);
|
|
14
|
-
return api;
|
|
15
|
-
},
|
|
16
|
-
get(path, handler) {
|
|
17
|
-
router.get(path, handler);
|
|
18
|
-
return api;
|
|
19
|
-
},
|
|
20
|
-
handle() {
|
|
21
|
-
return (event) => api.route(event);
|
|
22
|
-
},
|
|
23
|
-
patch(path, handler) {
|
|
24
|
-
router.patch(path, handler);
|
|
25
|
-
return api;
|
|
26
|
-
},
|
|
27
|
-
post(path, handler) {
|
|
28
|
-
router.post(path, handler);
|
|
29
|
-
return api;
|
|
30
|
-
},
|
|
31
|
-
put(path, handler) {
|
|
32
|
-
router.put(path, handler);
|
|
33
|
-
return api;
|
|
34
|
-
},
|
|
35
|
-
route: async (event) => {
|
|
36
|
-
const result = await router.route(event);
|
|
37
|
-
if (result.statusCode !== 404 || !isStaticMethod(event.httpMethod)) {
|
|
38
|
-
return result;
|
|
39
|
-
}
|
|
40
|
-
return spaHandler(event, {});
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
return api;
|
|
44
|
-
};
|