@vyriy/static 0.5.6 → 0.6.6

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
@@ -15,9 +15,9 @@ Serve a static directory:
15
15
  ```bash
16
16
  vyriy-static
17
17
  vyriy-static dist
18
- vyriy-static build
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 and router helpers from code:
46
+ Use direct handlers:
40
47
 
41
48
  ```ts
42
- import { staticServer, useSpa, useStatic, withSpa, withStatic } from '@vyriy/static';
43
- import { createRouter } from '@vyriy/router';
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
- export const assets = useStatic();
46
- export const app = useSpa({ directory: 'dist', index: 'index.html' });
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', { directory: 'dist' })
51
- .static('/', { directory: 'public' });
52
- export const spaRouter = withSpa(createRouter(), 'dist');
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
- const code = await staticServer({ directory: 'dist' });
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
- - `useStatic({ directory, index, error })` serves files from a directory.
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
- Defaults are `directory: 'dist'`, `index: 'index.html'`, and `error: '404.html'`.
64
- When `staticServer()` is called without options, it tries `dist`, `build`, `public`, `out`, and then the current directory.
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
@@ -0,0 +1,2 @@
1
+ export declare const getContentType: (extension: string) => string;
2
+ export declare const isTextExtension: (extension: string) => boolean;
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
@@ -2,5 +2,4 @@ export * from './server.js';
2
2
  export * from './use-static.js';
3
3
  export * from './use-spa.js';
4
4
  export * from './with-static.js';
5
- export * from './with-spa.js';
6
5
  export type * from './types.js';
package/index.js CHANGED
@@ -2,4 +2,3 @@ export * from './server.js';
2
2
  export * from './use-static.js';
3
3
  export * from './use-spa.js';
4
4
  export * from './with-static.js';
5
- export * from './with-spa.js';
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.5.6",
3
+ "version": "0.6.6",
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.5.6",
12
- "@vyriy/router": "0.5.6",
13
- "@vyriy/server": "0.5.6"
11
+ "@vyriy/handler": "0.6.6",
12
+ "@vyriy/router": "0.6.6",
13
+ "@vyriy/server": "0.6.6"
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,12 +1,14 @@
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',
7
8
  'build',
8
9
  'public',
9
10
  'out',
11
+ 'storybook-static',
10
12
  ];
11
13
  const getOptionValue = (args, name, alias) => {
12
14
  const inline = args.find((arg) => arg.startsWith(`${name}=`));
@@ -16,32 +18,85 @@ const getOptionValue = (args, name, alias) => {
16
18
  const index = args.findIndex((arg) => arg === name || arg === alias);
17
19
  return index >= 0 ? args[index + 1] : undefined;
18
20
  };
21
+ const hasOption = (args, name) => args.includes(name);
19
22
  const isOptionValue = (args, index) => {
20
23
  const previous = args[index - 1];
21
- return previous === '--port' || previous === '-p';
24
+ return (previous === '--cache' ||
25
+ previous === '--fallback' ||
26
+ previous === '--index' ||
27
+ previous === '--not-found' ||
28
+ previous === '--port' ||
29
+ previous === '-p');
22
30
  };
23
31
  const resolveDirectory = (directory) => directory ?? DIRECTORIES.find((candidate) => existsSync(candidate)) ?? '.';
24
32
  const createStaticHandler = useStatic;
33
+ const createSpaHandler = useSpa;
34
+ const normalizeCachePreset = (value) => {
35
+ if (!value) {
36
+ return undefined;
37
+ }
38
+ if (value === 'false' || value === 'none') {
39
+ return 'none';
40
+ }
41
+ if (value === 'default' || value === 'immutable' || value === 'static') {
42
+ return value;
43
+ }
44
+ throw new Error(`Unsupported static cache preset: ${value}`);
45
+ };
46
+ const getCliCacheDefault = () => (process.env.NODE_ENV === 'production' ? 'default' : 'none');
47
+ const withEnvDefaults = (command) => ({
48
+ cache: command.cache ?? normalizeCachePreset(process.env.VYRIY_STATIC_CACHE) ?? getCliCacheDefault(),
49
+ directory: command.directory,
50
+ fallback: command.fallback ?? process.env.VYRIY_STATIC_FALLBACK,
51
+ index: command.index ?? process.env.VYRIY_STATIC_INDEX,
52
+ notFound: command.notFound ?? process.env.VYRIY_STATIC_NOT_FOUND,
53
+ spa: command.spa,
54
+ });
25
55
  export const staticVersion = packageJson.version;
26
56
  export const createStaticHelpText = (command = 'vyriy-static', alias = 'vs') => {
27
57
  const aliasText = alias ? ` ${alias} [directory] Alias for ${command}\n` : '';
28
- const aliasExampleText = alias ? `\n ${alias} .` : '';
58
+ const aliasExampleText = alias ? `\n ${alias} .\n ${alias} --spa` : '';
29
59
  return `Vyriy Static Server
30
60
 
31
61
  Usage:
32
62
  ${command} [directory] Serve a static directory (defaults to dist when it exists)
63
+ ${command} [directory] --spa Serve a static directory in SPA fallback mode
33
64
  ${command} --port 3000 dist Serve a directory on a specific port
34
65
  ${aliasText}\
66
+ ${command} --spa Serve as an SPA with index fallback
67
+ ${command} --cache static Set cache preset: none, default, static, immutable
35
68
  ${command} --help, -h Show help
36
69
  ${command} --version, -v Show version
37
70
 
38
71
  Options:
39
- -p, --port <port> Port passed through the PORT environment variable
72
+ -p, --port <port> HTTP port. Default: PORT env or 3000
73
+ --cache <preset> Cache preset: none, default, static, immutable
74
+ --index <file> Static directory index file. Default: index.html
75
+ --not-found <file> Static 404 response file. Default: disabled
76
+ --spa Enable SPA fallback mode. Default: false
77
+ --fallback <file> SPA fallback file. Default: index.html
78
+
79
+ Defaults:
80
+ directory First existing: dist, build, public, out, then .
81
+ port PORT env or 3000
82
+ cache none when NODE_ENV is not production; default in production
83
+ index index.html
84
+ not-found disabled unless --not-found is provided
85
+ spa false
86
+ fallback index.html
87
+
88
+ Cache presets:
89
+ none Cache-Control: no-store
90
+ default public, max-age=3600 with validators
91
+ static immutable assets, revalidated HTML and metadata
92
+ immutable public, max-age=31536000, immutable
40
93
 
41
94
  Examples:
42
95
  ${command}
43
96
  ${command} public
44
- ${command} --port 3000 dist${aliasExampleText}`;
97
+ ${command} --port 3000 dist
98
+ ${command} dist --cache static
99
+ ${command} dist --spa --fallback index.html --cache static${aliasExampleText}`;
45
100
  };
46
101
  export const parseStaticBinArgs = (args) => {
47
102
  if (args.includes('--help') || args.includes('-h')) {
@@ -50,11 +105,33 @@ export const parseStaticBinArgs = (args) => {
50
105
  if (args.includes('--version') || args.includes('-v')) {
51
106
  return { type: 'version' };
52
107
  }
53
- return {
108
+ const cache = normalizeCachePreset(getOptionValue(args, '--cache', '--cache'));
109
+ const directory = args.find((arg, index) => !arg.startsWith('-') && !isOptionValue(args, index));
110
+ const fallback = getOptionValue(args, '--fallback', '--fallback');
111
+ const index = getOptionValue(args, '--index', '--index');
112
+ const notFound = getOptionValue(args, '--not-found', '--not-found');
113
+ const port = getOptionValue(args, '--port', '-p');
114
+ const command = {
54
115
  type: 'serve',
55
- directory: args.find((arg, index) => !arg.startsWith('-') && !isOptionValue(args, index)),
56
- port: getOptionValue(args, '--port', '-p'),
116
+ directory,
117
+ port,
57
118
  };
119
+ if (cache !== undefined) {
120
+ command.cache = cache;
121
+ }
122
+ if (fallback !== undefined) {
123
+ command.fallback = fallback;
124
+ }
125
+ if (index !== undefined) {
126
+ command.index = index;
127
+ }
128
+ if (notFound !== undefined) {
129
+ command.notFound = notFound;
130
+ }
131
+ if (hasOption(args, '--spa')) {
132
+ command.spa = true;
133
+ }
134
+ return command;
58
135
  };
59
136
  export const runStaticCli = async (args = [], command = 'vyriy-static', alias = 'vs') => {
60
137
  const parsed = parseStaticBinArgs(args);
@@ -71,11 +148,17 @@ export const runStaticCli = async (args = [], command = 'vyriy-static', alias =
71
148
  if (parsed.port) {
72
149
  process.env.PORT = parsed.port;
73
150
  }
74
- process.exitCode = await staticServer({ directory: parsed.directory });
151
+ process.exitCode = await staticServer(withEnvDefaults(parsed));
75
152
  break;
76
153
  }
77
154
  };
78
155
  export const staticServer = async (options = {}) => {
79
- await Promise.resolve(server(createStaticHandler({ ...options, directory: resolveDirectory(options.directory) })));
156
+ const handlerOptions = {
157
+ ...options,
158
+ directory: resolveDirectory(options.directory),
159
+ };
160
+ await Promise.resolve(server(options.spa
161
+ ? createSpaHandler(handlerOptions.directory, handlerOptions)
162
+ : createStaticHandler(handlerOptions.directory, handlerOptions)));
80
163
  return 0;
81
164
  };
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 StaticOptions = {
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 index?: string;
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?: StaticOptions) => Promise<number>;
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?: StaticMountOptions): StaticRouterApi;
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
- [Method in Extract<keyof RouterApi, 'delete' | 'fallback' | 'get' | 'patch' | 'post' | 'put'>]: (...args: Parameters<RouterApi[Method]>) => StaticRouterApi;
50
+ fallback(handler: RouterHandler): StaticRouterApi;
15
51
  };
package/use-spa.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import type { StaticHandler, StaticOptions } from './types.js';
2
- export declare const useSpa: (options?: StaticOptions) => StaticHandler;
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 { useStatic } from './use-static.js';
2
- const createStaticHandler = useStatic;
3
- const isSpaFallbackMethod = (method) => method === 'GET' || method === 'HEAD';
4
- export const useSpa = (options = {}) => {
5
- const handler = createStaticHandler(options);
6
- const index = options.index ?? 'index.html';
7
- return async (event, context) => {
8
- const result = await handler(event, context);
9
- if (result.statusCode !== 404 || !isSpaFallbackMethod(event.httpMethod)) {
10
- return result;
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, StaticOptions } from './types.js';
2
- export declare const useStatic: (options?: StaticOptions) => StaticHandler;
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, realpath, stat } from 'node:fs/promises';
1
+ import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- const DEFAULT_DIRECTORY = 'dist';
4
- const DEFAULT_INDEX = 'index.html';
5
- const DEFAULT_ERROR = '404.html';
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 CONTENT_TYPES = {
9
- '.css': 'text/css; charset=utf-8',
10
- '.csv': 'text/csv; charset=utf-8',
11
- '.gif': 'image/gif',
12
- '.html': 'text/html; charset=utf-8',
13
- '.ico': 'image/x-icon',
14
- '.jpeg': 'image/jpeg',
15
- '.jpg': 'image/jpeg',
16
- '.js': 'text/javascript; charset=utf-8',
17
- '.json': 'application/json; charset=utf-8',
18
- '.map': 'application/json; charset=utf-8',
19
- '.mjs': 'text/javascript; charset=utf-8',
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 TEXT_TYPES = new Set([
30
- '.css',
31
- '.csv',
32
- '.html',
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 isStaticMethod = (method) => method === 'GET' || method === 'HEAD';
59
- const isInsideDirectory = (directory, candidate) => {
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
- if (method === 'HEAD') {
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: 200,
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(realFilePath);
66
+ const content = await readFile(file.filePath);
83
67
  return {
84
- statusCode: 200,
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 readStaticFile = async (root, filePath, method) => {
94
- if (!isInsideDirectory(root, filePath)) {
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 ??= realpath(directory));
126
- return async (event) => {
127
- if (!isStaticMethod(event.httpMethod)) {
128
- return methodNotAllowed();
129
- }
130
- let decodedPath;
131
- try {
132
- decodedPath = decodeURIComponent(event.path);
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
- staticRoot = await getRoot();
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
- const requestedPath = decodedPath.replace(/^\/+/, '') || index;
145
- const candidate = path.resolve(staticRoot, requestedPath);
146
- if (!isInsideDirectory(staticRoot, candidate)) {
147
- return readErrorFile(staticRoot, error, event.httpMethod);
94
+ };
95
+ return async (event) => {
96
+ if (!isStaticMethod(event.httpMethod)) {
97
+ return methodNotAllowed();
148
98
  }
149
- let filePath = candidate;
99
+ let decodedPath;
150
100
  try {
151
- const fileStat = await stat(filePath);
152
- if (fileStat.isDirectory()) {
153
- filePath = path.join(filePath, index);
154
- }
101
+ decodedPath = decodeURIComponent(event.path);
155
102
  }
156
103
  catch {
157
- return readErrorFile(staticRoot, error, event.httpMethod);
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 readStaticFile(staticRoot, filePath, event.httpMethod);
164
- return result ?? readErrorFile(staticRoot, error, event.httpMethod);
107
+ const result = await readFileResult(event, decodedPath);
108
+ return result ?? readNotFoundFile(event);
165
109
  }
166
110
  catch {
167
- return readErrorFile(staticRoot, error, event.httpMethod);
111
+ return readNotFoundFile(event);
168
112
  }
169
113
  };
170
114
  };
package/with-static.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import type { StaticMountOptions, StaticRouterApi } from './types.js';
1
+ import type { StaticRouterApi } from './types.js';
2
2
  import type { RouterApi } from '@vyriy/router';
3
- export declare const withStatic: (router: RouterApi, options?: StaticMountOptions) => StaticRouterApi;
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 normalizeMount = (path) => {
3
- const mount = path.startsWith('/') ? path : `/${path}`;
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 toStaticPath = (mount, requestPath) => {
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 toStaticOptions = (options) => typeof options === 'string' ? { directory: options } : (options ?? {});
24
- export const withStatic = (router, options) => {
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
- const createStaticHandler = useStatic;
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 staticPath = toStaticPath(mount.path, event.path);
62
- if (staticPath === undefined) {
88
+ const mountedPath = toMountedPath(mount.route, event.path);
89
+ if (mountedPath === undefined) {
63
90
  continue;
64
91
  }
65
- const staticResult = await mount.handler({
92
+ return mount.handler({
66
93
  ...event,
67
- path: staticPath,
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(path, mountOptions) {
74
- const staticOptions = mountOptions ?? options;
75
- mounts.push({
76
- handler: createStaticHandler(toStaticOptions(staticOptions)),
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
@@ -1,3 +0,0 @@
1
- import type { StaticOptions } from './types.js';
2
- import type { RouterApi } from '@vyriy/router';
3
- export declare const withSpa: (router: RouterApi, directoryOrOptions?: StaticOptions | string) => RouterApi;
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
- };