asljs-server 0.2.0 → 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 +12 -3
- package/cli.js +1 -1
- 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 +122 -57
- 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,10 +26,19 @@ Or without installing:
|
|
|
26
26
|
- `asljs-server --port 8080`
|
|
27
27
|
- `asljs-server --host 0.0.0.0 --port 8080`
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
- Map a folder under a virtual path:
|
|
30
|
+
- `asljs-server --map assets=../Assets`
|
|
31
|
+
- Serves `../Assets/logo.png` as `/assets/logo.png`
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
## File API
|
|
34
|
+
|
|
35
|
+
- `GET /api/file?path=path` returns the file contents
|
|
36
|
+
- `PUT|POST /api/file?path=path` writes the file
|
|
37
|
+
- `GET /api/files?path=path` lists all files in the directory
|
|
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`
|
|
33
42
|
|
|
34
43
|
## Live reload
|
|
35
44
|
|
package/cli.js
CHANGED
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,22 +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;
|
|
7
|
+
import { VirtualFolders } from './virtual-folders.js';
|
|
8
|
+
/**
|
|
9
|
+
* Reads the body of the given request up to the given size limit.
|
|
10
|
+
*/
|
|
23
11
|
function readBody(request, limit) {
|
|
24
12
|
return new Promise((resolve, reject) => {
|
|
25
13
|
let size = 0;
|
|
@@ -41,11 +29,21 @@ function readBody(request, limit) {
|
|
|
41
29
|
request.on('error', reject);
|
|
42
30
|
});
|
|
43
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
|
+
`;
|
|
44
41
|
const options = { 'access-control-allow-origin': '*',
|
|
45
42
|
'access-control-allow-methods': 'GET,POST,PUT,OPTIONS',
|
|
46
43
|
'access-control-allow-headers': 'content-type' };
|
|
47
|
-
export function startServer({ root = '.', port = 3000, host = 'localhost', logger = console } = {}) {
|
|
44
|
+
export function startServer({ root = '.', port = 3000, host = 'localhost', logger = console, mounts = {} } = {}) {
|
|
48
45
|
const FILES_DIR = path.resolve(root);
|
|
46
|
+
const virtualFolders = new VirtualFolders(FILES_DIR, mounts);
|
|
49
47
|
// ---------- hot reload (SSE)
|
|
50
48
|
const sseClients = new Set();
|
|
51
49
|
const sseHeaders = { 'content-type': 'text/event-stream',
|
|
@@ -75,51 +73,53 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
|
|
|
75
73
|
}
|
|
76
74
|
}, 50);
|
|
77
75
|
};
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
: null;
|
|
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
|
+
}
|
|
85
|
+
// ---------- File API helpers
|
|
86
|
+
const fileApiPath = (relativePath) => {
|
|
87
|
+
return virtualFolders.toPhysicalPath(relativePath);
|
|
91
88
|
};
|
|
92
|
-
async function
|
|
89
|
+
async function handleFileApi(request, response, url) {
|
|
93
90
|
await fsp.mkdir(FILES_DIR, { recursive: true });
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
const relativePath = VirtualFolders.safeRelativePath(url.searchParams.get('path'));
|
|
92
|
+
if (!relativePath) {
|
|
93
|
+
return send.badRequest(response, 'Invalid "path". Use a relative path with URL-style separators.');
|
|
94
|
+
}
|
|
95
|
+
const filePath = fileApiPath(relativePath);
|
|
96
|
+
if (!filePath) {
|
|
97
|
+
return send.forbidden(response);
|
|
98
98
|
}
|
|
99
99
|
if (request.method === 'GET') {
|
|
100
|
-
|
|
101
|
-
return send.json(response, 200, await fsp.readFile(file, 'utf8'));
|
|
102
|
-
}
|
|
103
|
-
catch (e) {
|
|
104
|
-
if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT')
|
|
105
|
-
return send.notFound(response, 'JSON file not found');
|
|
106
|
-
throw e;
|
|
107
|
-
}
|
|
100
|
+
return send.file(response, filePath);
|
|
108
101
|
}
|
|
109
102
|
if (request.method === 'PUT'
|
|
110
103
|
|| request.method === 'POST') {
|
|
111
104
|
try {
|
|
112
105
|
// 1MB limit
|
|
113
106
|
const body = await readBody(request, 1000000);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
107
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
108
|
+
// Preserve the old behavior for JSON files.
|
|
109
|
+
if (path.extname(filePath).toLowerCase() === '.json') {
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(body);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return send.badRequest(response, 'Invalid JSON');
|
|
116
|
+
}
|
|
117
|
+
await fsp.writeFile(filePath, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
|
|
117
118
|
}
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
else {
|
|
120
|
+
await fsp.writeFile(filePath, body, 'utf8');
|
|
120
121
|
}
|
|
121
|
-
|
|
122
|
-
return send.api(response, { file: path.basename(file) });
|
|
122
|
+
return send.api(response, { path: relativePath });
|
|
123
123
|
}
|
|
124
124
|
catch (error) {
|
|
125
125
|
return send.apiError(response, error);
|
|
@@ -127,19 +127,73 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
|
|
|
127
127
|
}
|
|
128
128
|
return send.methodNotAllowed(response, 'GET, PUT, POST');
|
|
129
129
|
}
|
|
130
|
+
async function handleFilesApi(request, response, url) {
|
|
131
|
+
if (request.method !== 'GET') {
|
|
132
|
+
return send.methodNotAllowed(response, 'GET');
|
|
133
|
+
}
|
|
134
|
+
await fsp.mkdir(FILES_DIR, { recursive: true });
|
|
135
|
+
const relativePath = VirtualFolders.safeRelativePath(url.searchParams.get('path'), { allowEmpty: true });
|
|
136
|
+
if (relativePath === null) {
|
|
137
|
+
return send.badRequest(response, 'Invalid "path". Use a relative directory path.');
|
|
138
|
+
}
|
|
139
|
+
const directoryPath = fileApiPath(relativePath);
|
|
140
|
+
if (!directoryPath) {
|
|
141
|
+
return send.forbidden(response);
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const stat = await fsp.stat(directoryPath);
|
|
145
|
+
if (!stat.isDirectory()) {
|
|
146
|
+
return send.badRequest(response, '"path" must point to a directory');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error
|
|
151
|
+
&& typeof error === 'object'
|
|
152
|
+
&& 'code' in error
|
|
153
|
+
&& error.code === 'ENOENT') {
|
|
154
|
+
return send.notFound(response, 'Directory not found');
|
|
155
|
+
}
|
|
156
|
+
return send.error(response, error);
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const entries = await fsp.readdir(directoryPath, { withFileTypes: true });
|
|
160
|
+
const files = entries
|
|
161
|
+
.filter(e => e.isFile())
|
|
162
|
+
.map(e => e.name)
|
|
163
|
+
.sort((a, b) => a.localeCompare(b));
|
|
164
|
+
return send.api(response, { path: relativePath,
|
|
165
|
+
files });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return send.apiError(response, error);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
130
171
|
async function serveStatic(request, response, url) {
|
|
131
172
|
const pathname = decodeURIComponent(url.pathname);
|
|
132
173
|
// SSE endpoint
|
|
133
174
|
if (pathname === '/__events') {
|
|
134
175
|
return serveSSE(request, response);
|
|
135
176
|
}
|
|
136
|
-
//
|
|
137
|
-
if (pathname === '/api/
|
|
138
|
-
return
|
|
177
|
+
// File API: /api/file?path=relative/path.ext
|
|
178
|
+
if (pathname === '/api/file') {
|
|
179
|
+
return handleFileApi(request, response, url);
|
|
180
|
+
}
|
|
181
|
+
// Directory listing API: /api/files?path=relative/dir
|
|
182
|
+
if (pathname === '/api/files') {
|
|
183
|
+
return handleFilesApi(request, response, url);
|
|
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);
|
|
139
191
|
}
|
|
140
192
|
// Resolve file path
|
|
141
|
-
let filePath =
|
|
142
|
-
|
|
193
|
+
let filePath = safeUrlPath === ''
|
|
194
|
+
? FILES_DIR
|
|
195
|
+
: virtualFolders.toPhysicalPath(safeUrlPath);
|
|
196
|
+
if (!filePath) {
|
|
143
197
|
return send.forbidden(response);
|
|
144
198
|
}
|
|
145
199
|
// If path is dir, try index.html
|
|
@@ -163,8 +217,8 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
|
|
|
163
217
|
const html = await fsp.readFile(filePath, 'utf8');
|
|
164
218
|
const htmlWithReload = html.includes('/__events')
|
|
165
219
|
? html
|
|
166
|
-
: html.replace(/<\/body\s*>/i, m => `${RELOAD_SNIPPET}\n${m}`)
|
|
167
|
-
(!html.match(/<\/body\s*>/i)
|
|
220
|
+
: html.replace(/<\/body\s*>/i, m => `${RELOAD_SNIPPET}\n${m}`)
|
|
221
|
+
+ (!html.match(/<\/body\s*>/i)
|
|
168
222
|
? `\n${RELOAD_SNIPPET}`
|
|
169
223
|
: '');
|
|
170
224
|
return send.html(response, htmlWithReload);
|
|
@@ -186,8 +240,19 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
|
|
|
186
240
|
send.error(response, error);
|
|
187
241
|
});
|
|
188
242
|
});
|
|
243
|
+
server.on('close', () => {
|
|
244
|
+
for (const stop of stopWatchers) {
|
|
245
|
+
try {
|
|
246
|
+
stop();
|
|
247
|
+
}
|
|
248
|
+
catch { }
|
|
249
|
+
}
|
|
250
|
+
});
|
|
189
251
|
server.listen(port, host, () => {
|
|
190
252
|
logger.log(`FILES: ${FILES_DIR}`);
|
|
253
|
+
for (const mount of virtualFolders.mounts) {
|
|
254
|
+
logger.log(`MAP: /${mount.virtual} -> ${mount.rootDir}`);
|
|
255
|
+
}
|
|
191
256
|
logger.log(`URL: http://${host}:${port}`);
|
|
192
257
|
});
|
|
193
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