asljs-server 0.2.1 → 0.2.2
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 +8 -0
- package/dist/cli.js +32 -2
- package/dist/send.d.ts +52 -0
- package/dist/send.js +54 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.js +51 -58
- package/dist/virtual-folders.d.ts +19 -0
- package/dist/virtual-folders.js +102 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,12 +26,20 @@ Or without installing:
|
|
|
26
26
|
- `asljs-server --port 8080`
|
|
27
27
|
- `asljs-server --host 0.0.0.0 --port 8080`
|
|
28
28
|
|
|
29
|
+
- Map a folder under a virtual path:
|
|
30
|
+
- `asljs-server --map assets=../Assets`
|
|
31
|
+
- Serves `../Assets/logo.png` as `/assets/logo.png`
|
|
32
|
+
|
|
29
33
|
## File API
|
|
30
34
|
|
|
31
35
|
- `GET /api/file?path=path` returns the file contents
|
|
32
36
|
- `PUT|POST /api/file?path=path` writes the file
|
|
33
37
|
- `GET /api/files?path=path` lists all files in the directory
|
|
34
38
|
|
|
39
|
+
Folder mappings apply to the File API too. For example, with `--map assets=../Assets`:
|
|
40
|
+
|
|
41
|
+
- `GET /api/file?path=assets/logo.png`
|
|
42
|
+
|
|
35
43
|
## Live reload
|
|
36
44
|
|
|
37
45
|
HTML pages get a small client injected that listens on:
|
package/dist/cli.js
CHANGED
|
@@ -6,10 +6,12 @@ function printHelp() {
|
|
|
6
6
|
process.stdout.write(`asljs-server
|
|
7
7
|
|
|
8
8
|
Usage:
|
|
9
|
-
asljs-server [--root <dir>] [--port <number>] [--host <name>]
|
|
9
|
+
asljs-server [--root <dir>] [--port <number>] [--host <name>] [--map <virtual>=<dir>]
|
|
10
10
|
|
|
11
11
|
Options:
|
|
12
12
|
--root <dir> Root directory to serve (default: .)
|
|
13
|
+
--map <spec> Map a folder under a virtual path (repeatable)
|
|
14
|
+
Spec: <virtual>=<dir> (example: assets=../Assets)
|
|
13
15
|
--port <number> Port to listen on (default: 3000)
|
|
14
16
|
--host <name> Host/interface to bind (default: localhost)
|
|
15
17
|
--help Show this help
|
|
@@ -26,7 +28,25 @@ function readVersion() {
|
|
|
26
28
|
function parseArgs(argv) {
|
|
27
29
|
const options = { root: '.',
|
|
28
30
|
port: 3000,
|
|
29
|
-
host: 'localhost'
|
|
31
|
+
host: 'localhost',
|
|
32
|
+
mounts: {} };
|
|
33
|
+
const addMount = (spec) => {
|
|
34
|
+
// Use '=' to avoid Windows drive-letter ambiguity.
|
|
35
|
+
const separatorIndex = spec.indexOf('=');
|
|
36
|
+
if (separatorIndex <= 0
|
|
37
|
+
|| separatorIndex >= spec.length - 1) {
|
|
38
|
+
throw new TypeError('Invalid --map spec. Use <virtual>=<dir>');
|
|
39
|
+
}
|
|
40
|
+
const virtual = spec.slice(0, separatorIndex);
|
|
41
|
+
const dir = spec.slice(separatorIndex + 1);
|
|
42
|
+
if (!/^[a-zA-Z0-9._/-]+$/.test(virtual)
|
|
43
|
+
|| virtual.startsWith('/')
|
|
44
|
+
|| virtual.includes('\\')
|
|
45
|
+
|| virtual.split('/').some(p => !p || p === '.' || p === '..')) {
|
|
46
|
+
throw new TypeError('Invalid virtual path in --map spec');
|
|
47
|
+
}
|
|
48
|
+
options.mounts[virtual] = dir;
|
|
49
|
+
};
|
|
30
50
|
for (let i = 0; i < argv.length; i++) {
|
|
31
51
|
const arg = argv[i];
|
|
32
52
|
if (arg === '--help'
|
|
@@ -66,6 +86,16 @@ function parseArgs(argv) {
|
|
|
66
86
|
options.host = value;
|
|
67
87
|
continue;
|
|
68
88
|
}
|
|
89
|
+
if (arg === '--map'
|
|
90
|
+
|| arg === '--mount'
|
|
91
|
+
|| arg === '-m') {
|
|
92
|
+
const value = argv[++i];
|
|
93
|
+
if (!value) {
|
|
94
|
+
throw new TypeError('Missing value for --map');
|
|
95
|
+
}
|
|
96
|
+
addMount(value);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
69
99
|
if (arg.startsWith('-')) {
|
|
70
100
|
throw new TypeError(`Unknown option: ${arg}`);
|
|
71
101
|
}
|
package/dist/send.d.ts
CHANGED
|
@@ -1,13 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functions for sending HTTP responses.
|
|
3
|
+
*
|
|
4
|
+
* Exported helpers:
|
|
5
|
+
*
|
|
6
|
+
* - `options(ServerResponse, OutgoingHttpHeaders): void`
|
|
7
|
+
* - `api(ServerResponse, unknown): void`
|
|
8
|
+
* - `apiError(ServerResponse, unknown): void`
|
|
9
|
+
* - `json(ServerResponse, number, unknown): void`
|
|
10
|
+
* - `html(ServerResponse, string): void`
|
|
11
|
+
* - `badRequest(ServerResponse, string?): void`
|
|
12
|
+
* - `forbidden(ServerResponse, string?): void`
|
|
13
|
+
* - `notFound(ServerResponse, string?): void`
|
|
14
|
+
* - `methodNotAllowed(ServerResponse, string?): void`
|
|
15
|
+
* - `error(ServerResponse, unknown): void`
|
|
16
|
+
* - `file(ServerResponse, string): Promise<void>`
|
|
17
|
+
*/
|
|
1
18
|
import type { OutgoingHttpHeaders } from 'node:http';
|
|
2
19
|
import type { ServerResponse } from 'node:http';
|
|
20
|
+
/**
|
|
21
|
+
* Sends an empty response to the client for an OPTIONS request.
|
|
22
|
+
* Typically used for CORS preflight requests.
|
|
23
|
+
*/
|
|
3
24
|
export declare function options(response: ServerResponse, headers: OutgoingHttpHeaders): void;
|
|
25
|
+
/**
|
|
26
|
+
* Sends a JSON API response with status 200.
|
|
27
|
+
*/
|
|
4
28
|
export declare function api(response: ServerResponse, payload: unknown): void;
|
|
29
|
+
/**
|
|
30
|
+
* Sends an API error message to the response with status 500.
|
|
31
|
+
*/
|
|
5
32
|
export declare function apiError(response: ServerResponse, error: unknown): void;
|
|
33
|
+
/**
|
|
34
|
+
* Sends a JSON string to the response with the specified status.
|
|
35
|
+
*/
|
|
6
36
|
export declare function json(response: ServerResponse, status: number, jsonString: unknown): void;
|
|
37
|
+
/**
|
|
38
|
+
* Sends an HTML string to the response with status 200.
|
|
39
|
+
*/
|
|
7
40
|
export declare function html(response: ServerResponse, htmlString: string): void;
|
|
41
|
+
/**
|
|
42
|
+
* Sends a "Bad request" error message to the response with status 400.
|
|
43
|
+
*/
|
|
8
44
|
export declare function badRequest(response: ServerResponse, message?: string): void;
|
|
45
|
+
/**
|
|
46
|
+
* Sends a "Forbidden" error message to the response with status 403.
|
|
47
|
+
*/
|
|
9
48
|
export declare function forbidden(response: ServerResponse, message?: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Sends a "Not found" error message to the response with status 404.
|
|
51
|
+
*/
|
|
10
52
|
export declare function notFound(response: ServerResponse, message?: string): void;
|
|
53
|
+
/**
|
|
54
|
+
* Sends a "Method not allowed" error message to the response with status 405.
|
|
55
|
+
*/
|
|
11
56
|
export declare function methodNotAllowed(response: ServerResponse, allowed?: string): void;
|
|
57
|
+
/**
|
|
58
|
+
* Sends an error message to the response with status 500.
|
|
59
|
+
*/
|
|
12
60
|
export declare function error(response: ServerResponse, err: unknown): void;
|
|
61
|
+
/**
|
|
62
|
+
* Sends a static file, specified by `filePath`, to the response,
|
|
63
|
+
* asynchronously.
|
|
64
|
+
*/
|
|
13
65
|
export declare function file(response: ServerResponse, filePath: string): Promise<void>;
|
package/dist/send.js
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functions for sending HTTP responses.
|
|
3
|
+
*
|
|
4
|
+
* Exported helpers:
|
|
5
|
+
*
|
|
6
|
+
* - `options(ServerResponse, OutgoingHttpHeaders): void`
|
|
7
|
+
* - `api(ServerResponse, unknown): void`
|
|
8
|
+
* - `apiError(ServerResponse, unknown): void`
|
|
9
|
+
* - `json(ServerResponse, number, unknown): void`
|
|
10
|
+
* - `html(ServerResponse, string): void`
|
|
11
|
+
* - `badRequest(ServerResponse, string?): void`
|
|
12
|
+
* - `forbidden(ServerResponse, string?): void`
|
|
13
|
+
* - `notFound(ServerResponse, string?): void`
|
|
14
|
+
* - `methodNotAllowed(ServerResponse, string?): void`
|
|
15
|
+
* - `error(ServerResponse, unknown): void`
|
|
16
|
+
* - `file(ServerResponse, string): Promise<void>`
|
|
17
|
+
*/
|
|
1
18
|
import fs from 'node:fs';
|
|
2
19
|
import fsp from 'node:fs/promises';
|
|
3
20
|
import path from 'node:path';
|
|
21
|
+
// Map of file extensions to content types.
|
|
22
|
+
// Used by `file()` to set the Content-Type header.
|
|
4
23
|
const CONTENT_TYPES = new Map([['.html', 'text/html; charset=utf-8'],
|
|
5
24
|
['.htm', 'text/html; charset=utf-8'],
|
|
6
25
|
['.js', 'text/javascript; charset=utf-8'],
|
|
@@ -23,14 +42,24 @@ function contentTypeFor(filePath) {
|
|
|
23
42
|
return CONTENT_TYPES.get(ext)
|
|
24
43
|
|| 'application/octet-stream';
|
|
25
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Sends an empty response to the client for an OPTIONS request.
|
|
47
|
+
* Typically used for CORS preflight requests.
|
|
48
|
+
*/
|
|
26
49
|
export function options(response, headers) {
|
|
27
50
|
response.writeHead(204, headers);
|
|
28
51
|
response.end();
|
|
29
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Sends a JSON API response with status 200.
|
|
55
|
+
*/
|
|
30
56
|
export function api(response, payload) {
|
|
31
57
|
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
32
58
|
response.end(JSON.stringify(payload));
|
|
33
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Sends an API error message to the response with status 500.
|
|
62
|
+
*/
|
|
34
63
|
export function apiError(response, error) {
|
|
35
64
|
response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
|
|
36
65
|
const message = (error && typeof error === 'object' && 'message' in error)
|
|
@@ -38,33 +67,54 @@ export function apiError(response, error) {
|
|
|
38
67
|
: String(error);
|
|
39
68
|
response.end(JSON.stringify({ error: message }));
|
|
40
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Sends a JSON string to the response with the specified status.
|
|
72
|
+
*/
|
|
41
73
|
export function json(response, status, jsonString) {
|
|
42
74
|
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
43
75
|
response.end(typeof jsonString === 'string'
|
|
44
76
|
? jsonString
|
|
45
77
|
: JSON.stringify(jsonString));
|
|
46
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Sends an HTML string to the response with status 200.
|
|
81
|
+
*/
|
|
47
82
|
export function html(response, htmlString) {
|
|
48
83
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
49
84
|
response.end(htmlString);
|
|
50
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Sends a "Bad request" error message to the response with status 400.
|
|
88
|
+
*/
|
|
51
89
|
export function badRequest(response, message = 'Bad request') {
|
|
52
90
|
response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
53
91
|
response.end(message);
|
|
54
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Sends a "Forbidden" error message to the response with status 403.
|
|
95
|
+
*/
|
|
55
96
|
export function forbidden(response, message = 'Forbidden') {
|
|
56
97
|
response.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
57
98
|
response.end(message);
|
|
58
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Sends a "Not found" error message to the response with status 404.
|
|
102
|
+
*/
|
|
59
103
|
export function notFound(response, message = 'Not found') {
|
|
60
104
|
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
61
105
|
response.end(message);
|
|
62
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Sends a "Method not allowed" error message to the response with status 405.
|
|
109
|
+
*/
|
|
63
110
|
export function methodNotAllowed(response, allowed = 'GET') {
|
|
64
111
|
response.writeHead(405, { 'content-type': 'text/plain; charset=utf-8',
|
|
65
112
|
'allow': allowed });
|
|
66
113
|
response.end('Method not allowed');
|
|
67
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Sends an error message to the response with status 500.
|
|
117
|
+
*/
|
|
68
118
|
export function error(response, err) {
|
|
69
119
|
response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
70
120
|
const message = (err && typeof err === 'object' && 'stack' in err)
|
|
@@ -74,6 +124,10 @@ export function error(response, err) {
|
|
|
74
124
|
: String(err);
|
|
75
125
|
response.end(message);
|
|
76
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Sends a static file, specified by `filePath`, to the response,
|
|
129
|
+
* asynchronously.
|
|
130
|
+
*/
|
|
77
131
|
export async function file(response, filePath) {
|
|
78
132
|
try {
|
|
79
133
|
const stat = await fsp.stat(filePath);
|
package/dist/server.d.ts
CHANGED
|
@@ -8,5 +8,6 @@ export type StartServerOptions = {
|
|
|
8
8
|
port?: number;
|
|
9
9
|
host?: string;
|
|
10
10
|
logger?: ServerLogger;
|
|
11
|
+
mounts?: Record<string, string>;
|
|
11
12
|
};
|
|
12
|
-
export declare function startServer({ root, port, host, logger }?: StartServerOptions): Server;
|
|
13
|
+
export declare function startServer({ root, port, host, logger, mounts }?: StartServerOptions): Server;
|
package/dist/server.js
CHANGED
|
@@ -4,52 +4,10 @@ import path from 'node:path';
|
|
|
4
4
|
import { URL, pathToFileURL } from 'node:url';
|
|
5
5
|
import * as send from './send.js';
|
|
6
6
|
import { watchStaticTree } from './watch.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
// inject minimal client into served HTML
|
|
13
|
-
const RELOAD_SNIPPET = `<script>
|
|
14
|
-
try {
|
|
15
|
-
const es = new EventSource('/__events');
|
|
16
|
-
es.addEventListener('reload', () => location.reload());
|
|
17
|
-
} catch (_) {}
|
|
18
|
-
</script>`;
|
|
19
|
-
const safeJsonName = (name) => typeof name === 'string'
|
|
20
|
-
&& /^[a-zA-Z0-9._/-]+$/.test(name)
|
|
21
|
-
? name
|
|
22
|
-
: null;
|
|
23
|
-
const safeRelativeApiPath = (value, { allowEmpty = false } = {}) => {
|
|
24
|
-
if (value === null
|
|
25
|
-
|| value === undefined) {
|
|
26
|
-
return allowEmpty
|
|
27
|
-
? ''
|
|
28
|
-
: null;
|
|
29
|
-
}
|
|
30
|
-
if (typeof value !== 'string')
|
|
31
|
-
return null;
|
|
32
|
-
if (value === '')
|
|
33
|
-
return allowEmpty
|
|
34
|
-
? ''
|
|
35
|
-
: null;
|
|
36
|
-
if (!/^[a-zA-Z0-9._/-]+$/.test(value))
|
|
37
|
-
return null;
|
|
38
|
-
// Force URL-style separators.
|
|
39
|
-
if (value.includes('\\'))
|
|
40
|
-
return null;
|
|
41
|
-
if (value.startsWith('/'))
|
|
42
|
-
return null;
|
|
43
|
-
const parts = value.split('/');
|
|
44
|
-
for (const part of parts) {
|
|
45
|
-
if (!part
|
|
46
|
-
|| part === '.'
|
|
47
|
-
|| part === '..') {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return value;
|
|
52
|
-
};
|
|
7
|
+
import { VirtualFolders } from './virtual-folders.js';
|
|
8
|
+
/**
|
|
9
|
+
* Reads the body of the given request up to the given size limit.
|
|
10
|
+
*/
|
|
53
11
|
function readBody(request, limit) {
|
|
54
12
|
return new Promise((resolve, reject) => {
|
|
55
13
|
let size = 0;
|
|
@@ -71,11 +29,21 @@ function readBody(request, limit) {
|
|
|
71
29
|
request.on('error', reject);
|
|
72
30
|
});
|
|
73
31
|
}
|
|
32
|
+
// inject minimal client into served HTML
|
|
33
|
+
const RELOAD_SNIPPET = `
|
|
34
|
+
<script>
|
|
35
|
+
try {
|
|
36
|
+
const es = new EventSource('/__events');
|
|
37
|
+
es.addEventListener('reload', () => location.reload());
|
|
38
|
+
} catch (_) {}
|
|
39
|
+
</script>
|
|
40
|
+
`;
|
|
74
41
|
const options = { 'access-control-allow-origin': '*',
|
|
75
42
|
'access-control-allow-methods': 'GET,POST,PUT,OPTIONS',
|
|
76
43
|
'access-control-allow-headers': 'content-type' };
|
|
77
|
-
export function startServer({ root = '.', port = 3000, host = 'localhost', logger = console } = {}) {
|
|
44
|
+
export function startServer({ root = '.', port = 3000, host = 'localhost', logger = console, mounts = {} } = {}) {
|
|
78
45
|
const FILES_DIR = path.resolve(root);
|
|
46
|
+
const virtualFolders = new VirtualFolders(FILES_DIR, mounts);
|
|
79
47
|
// ---------- hot reload (SSE)
|
|
80
48
|
const sseClients = new Set();
|
|
81
49
|
const sseHeaders = { 'content-type': 'text/event-stream',
|
|
@@ -105,17 +73,22 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
|
|
|
105
73
|
}
|
|
106
74
|
}, 50);
|
|
107
75
|
};
|
|
108
|
-
|
|
76
|
+
const stopWatchers = [];
|
|
77
|
+
for (const watchRoot of virtualFolders.getWatchRoots()) {
|
|
78
|
+
try {
|
|
79
|
+
stopWatchers.push(watchStaticTree(watchRoot, () => broadcastReload()));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// ignore
|
|
83
|
+
}
|
|
84
|
+
}
|
|
109
85
|
// ---------- File API helpers
|
|
110
86
|
const fileApiPath = (relativePath) => {
|
|
111
|
-
|
|
112
|
-
return isPathInside(full, FILES_DIR)
|
|
113
|
-
? full
|
|
114
|
-
: null;
|
|
87
|
+
return virtualFolders.toPhysicalPath(relativePath);
|
|
115
88
|
};
|
|
116
89
|
async function handleFileApi(request, response, url) {
|
|
117
90
|
await fsp.mkdir(FILES_DIR, { recursive: true });
|
|
118
|
-
const relativePath =
|
|
91
|
+
const relativePath = VirtualFolders.safeRelativePath(url.searchParams.get('path'));
|
|
119
92
|
if (!relativePath) {
|
|
120
93
|
return send.badRequest(response, 'Invalid "path". Use a relative path with URL-style separators.');
|
|
121
94
|
}
|
|
@@ -159,7 +132,7 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
|
|
|
159
132
|
return send.methodNotAllowed(response, 'GET');
|
|
160
133
|
}
|
|
161
134
|
await fsp.mkdir(FILES_DIR, { recursive: true });
|
|
162
|
-
const relativePath =
|
|
135
|
+
const relativePath = VirtualFolders.safeRelativePath(url.searchParams.get('path'), { allowEmpty: true });
|
|
163
136
|
if (relativePath === null) {
|
|
164
137
|
return send.badRequest(response, 'Invalid "path". Use a relative directory path.');
|
|
165
138
|
}
|
|
@@ -209,9 +182,18 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
|
|
|
209
182
|
if (pathname === '/api/files') {
|
|
210
183
|
return handleFilesApi(request, response, url);
|
|
211
184
|
}
|
|
185
|
+
const relativeUrlPath = pathname.startsWith('/')
|
|
186
|
+
? pathname.slice(1)
|
|
187
|
+
: pathname;
|
|
188
|
+
const safeUrlPath = VirtualFolders.safeRelativePath(relativeUrlPath, { allowEmpty: true });
|
|
189
|
+
if (safeUrlPath === null) {
|
|
190
|
+
return send.forbidden(response);
|
|
191
|
+
}
|
|
212
192
|
// Resolve file path
|
|
213
|
-
let filePath =
|
|
214
|
-
|
|
193
|
+
let filePath = safeUrlPath === ''
|
|
194
|
+
? FILES_DIR
|
|
195
|
+
: virtualFolders.toPhysicalPath(safeUrlPath);
|
|
196
|
+
if (!filePath) {
|
|
215
197
|
return send.forbidden(response);
|
|
216
198
|
}
|
|
217
199
|
// If path is dir, try index.html
|
|
@@ -235,8 +217,8 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
|
|
|
235
217
|
const html = await fsp.readFile(filePath, 'utf8');
|
|
236
218
|
const htmlWithReload = html.includes('/__events')
|
|
237
219
|
? html
|
|
238
|
-
: html.replace(/<\/body\s*>/i, m => `${RELOAD_SNIPPET}\n${m}`)
|
|
239
|
-
(!html.match(/<\/body\s*>/i)
|
|
220
|
+
: html.replace(/<\/body\s*>/i, m => `${RELOAD_SNIPPET}\n${m}`)
|
|
221
|
+
+ (!html.match(/<\/body\s*>/i)
|
|
240
222
|
? `\n${RELOAD_SNIPPET}`
|
|
241
223
|
: '');
|
|
242
224
|
return send.html(response, htmlWithReload);
|
|
@@ -258,8 +240,19 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
|
|
|
258
240
|
send.error(response, error);
|
|
259
241
|
});
|
|
260
242
|
});
|
|
243
|
+
server.on('close', () => {
|
|
244
|
+
for (const stop of stopWatchers) {
|
|
245
|
+
try {
|
|
246
|
+
stop();
|
|
247
|
+
}
|
|
248
|
+
catch { }
|
|
249
|
+
}
|
|
250
|
+
});
|
|
261
251
|
server.listen(port, host, () => {
|
|
262
252
|
logger.log(`FILES: ${FILES_DIR}`);
|
|
253
|
+
for (const mount of virtualFolders.mounts) {
|
|
254
|
+
logger.log(`MAP: /${mount.virtual} -> ${mount.rootDir}`);
|
|
255
|
+
}
|
|
263
256
|
logger.log(`URL: http://${host}:${port}`);
|
|
264
257
|
});
|
|
265
258
|
return server;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type VerifySafeRelativePathOptions = {
|
|
2
|
+
allowEmpty?: boolean;
|
|
3
|
+
};
|
|
4
|
+
export type VirtualFolderMount = {
|
|
5
|
+
virtual: string;
|
|
6
|
+
rootDir: string;
|
|
7
|
+
};
|
|
8
|
+
export declare class VirtualFolders {
|
|
9
|
+
readonly baseDir: string;
|
|
10
|
+
readonly mounts: ReadonlyArray<VirtualFolderMount>;
|
|
11
|
+
constructor(baseDir: string, mounts?: Record<string, string>);
|
|
12
|
+
static safeRelativePath(value: string | null | undefined, { allowEmpty }?: VerifySafeRelativePathOptions): string | null;
|
|
13
|
+
getWatchRoots(): ReadonlyArray<string>;
|
|
14
|
+
resolve(relativePath: string): {
|
|
15
|
+
rootDir: string;
|
|
16
|
+
relativePath: string;
|
|
17
|
+
};
|
|
18
|
+
toPhysicalPath(relativePath: string): string | null;
|
|
19
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
function isPathInside(child, parent) {
|
|
3
|
+
const childPath = path.resolve(path.normalize(child));
|
|
4
|
+
const parentPath = path.resolve(path.normalize(parent));
|
|
5
|
+
return childPath === parentPath
|
|
6
|
+
|| childPath.startsWith(parentPath + path.sep);
|
|
7
|
+
}
|
|
8
|
+
export class VirtualFolders {
|
|
9
|
+
constructor(baseDir, mounts = {}) {
|
|
10
|
+
if (!baseDir
|
|
11
|
+
|| typeof baseDir !== 'string') {
|
|
12
|
+
throw new TypeError('VirtualFolders: baseDir must be a string');
|
|
13
|
+
}
|
|
14
|
+
this.baseDir =
|
|
15
|
+
path.resolve(path.normalize(baseDir));
|
|
16
|
+
this.mounts =
|
|
17
|
+
Object.entries(mounts)
|
|
18
|
+
.map(([virtual, dir]) => {
|
|
19
|
+
const safeVirtual = VirtualFolders.safeRelativePath(virtual);
|
|
20
|
+
if (!safeVirtual) {
|
|
21
|
+
throw new TypeError('Invalid mount virtual path');
|
|
22
|
+
}
|
|
23
|
+
if (typeof dir !== 'string') {
|
|
24
|
+
throw new TypeError('Invalid mount dir');
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
virtual: safeVirtual,
|
|
28
|
+
rootDir: path.resolve(path.normalize(dir))
|
|
29
|
+
};
|
|
30
|
+
})
|
|
31
|
+
// Prefer longer (more specific) virtual paths.
|
|
32
|
+
.sort((a, b) => b.virtual.length
|
|
33
|
+
- a.virtual.length);
|
|
34
|
+
}
|
|
35
|
+
static safeRelativePath(value, { allowEmpty = false } = {}) {
|
|
36
|
+
if (value === null
|
|
37
|
+
|| value === undefined) {
|
|
38
|
+
return allowEmpty
|
|
39
|
+
? ''
|
|
40
|
+
: null;
|
|
41
|
+
}
|
|
42
|
+
if (typeof value !== 'string')
|
|
43
|
+
return null;
|
|
44
|
+
if (value === '') {
|
|
45
|
+
return allowEmpty
|
|
46
|
+
? ''
|
|
47
|
+
: null;
|
|
48
|
+
}
|
|
49
|
+
if (!/^[a-zA-Z0-9._/-]+$/.test(value))
|
|
50
|
+
return null;
|
|
51
|
+
// Force URL-style separators.
|
|
52
|
+
if (value.includes('\\'))
|
|
53
|
+
return null;
|
|
54
|
+
if (value.startsWith('/'))
|
|
55
|
+
return null;
|
|
56
|
+
const segments = value.split('/');
|
|
57
|
+
for (const segment of segments) {
|
|
58
|
+
if (!segment
|
|
59
|
+
|| segment === '.'
|
|
60
|
+
|| segment === '..') {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
getWatchRoots() {
|
|
67
|
+
const set = new Set();
|
|
68
|
+
set.add(this.baseDir);
|
|
69
|
+
for (const mount of this.mounts) {
|
|
70
|
+
set.add(mount.rootDir);
|
|
71
|
+
}
|
|
72
|
+
return Array.from(set);
|
|
73
|
+
}
|
|
74
|
+
resolve(relativePath) {
|
|
75
|
+
for (const mount of this.mounts) {
|
|
76
|
+
if (relativePath === mount.virtual) {
|
|
77
|
+
return {
|
|
78
|
+
rootDir: mount.rootDir,
|
|
79
|
+
relativePath: ''
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const prefix = mount.virtual + '/';
|
|
83
|
+
if (relativePath.startsWith(prefix)) {
|
|
84
|
+
return {
|
|
85
|
+
rootDir: mount.rootDir,
|
|
86
|
+
relativePath: relativePath.slice(prefix.length)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
rootDir: this.baseDir,
|
|
92
|
+
relativePath
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
toPhysicalPath(relativePath) {
|
|
96
|
+
const resolved = this.resolve(relativePath);
|
|
97
|
+
const full = path.join(resolved.rootDir, resolved.relativePath);
|
|
98
|
+
return isPathInside(full, resolved.rootDir)
|
|
99
|
+
? full
|
|
100
|
+
: null;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/package.json
CHANGED