asljs-server 0.2.2 → 0.2.3
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 +2 -2
- package/dist/cli.js +77 -113
- package/dist/module.d.ts +5 -0
- package/dist/module.js +1 -0
- package/dist/modules/filesystem-api.d.ts +12 -0
- package/dist/modules/filesystem-api.js +98 -0
- package/dist/modules/live-reload.d.ts +18 -0
- package/dist/modules/live-reload.js +98 -0
- package/dist/modules/static-files.d.ts +10 -0
- package/dist/modules/static-files.js +58 -0
- package/dist/receive.d.ts +6 -0
- package/dist/receive.js +48 -0
- package/dist/send.js +3 -0
- package/dist/server.d.ts +16 -2
- package/dist/server.js +58 -243
- package/dist/virtual-folders.d.ts +2 -4
- package/dist/virtual-folders.js +34 -62
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Or without installing:
|
|
|
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 --
|
|
30
|
+
- `asljs-server --mount assets=../Assets`
|
|
31
31
|
- Serves `../Assets/logo.png` as `/assets/logo.png`
|
|
32
32
|
|
|
33
33
|
## File API
|
|
@@ -36,7 +36,7 @@ Or without installing:
|
|
|
36
36
|
- `PUT|POST /api/file?path=path` writes the file
|
|
37
37
|
- `GET /api/files?path=path` lists all files in the directory
|
|
38
38
|
|
|
39
|
-
Folder mappings apply to the File API too. For example, with `--
|
|
39
|
+
Folder mappings apply to the File API too. For example, with `--mount assets=../Assets`:
|
|
40
40
|
|
|
41
41
|
- `GET /api/file?path=assets/logo.png`
|
|
42
42
|
|
package/dist/cli.js
CHANGED
|
@@ -2,22 +2,10 @@ import { startServer } from './server.js';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
asljs-server [--root <dir>] [--port <number>] [--host <name>] [--map <virtual>=<dir>]
|
|
10
|
-
|
|
11
|
-
Options:
|
|
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)
|
|
15
|
-
--port <number> Port to listen on (default: 3000)
|
|
16
|
-
--host <name> Host/interface to bind (default: localhost)
|
|
17
|
-
--help Show this help
|
|
18
|
-
--version Print version
|
|
19
|
-
`);
|
|
20
|
-
}
|
|
5
|
+
import yargs from 'yargs';
|
|
6
|
+
/**
|
|
7
|
+
* Reads the asljs-server version from package.json.
|
|
8
|
+
*/
|
|
21
9
|
function readVersion() {
|
|
22
10
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
23
11
|
const packageJsonPath = path.join(here, '..', 'package.json');
|
|
@@ -25,108 +13,84 @@ function readVersion() {
|
|
|
25
13
|
return JSON.parse(raw)
|
|
26
14
|
.version;
|
|
27
15
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Converts array of strings `[ "<virtual>=<dir>", ... ]` into a map.
|
|
18
|
+
*/
|
|
19
|
+
function parseMounts(mounts) {
|
|
20
|
+
const map = {};
|
|
21
|
+
if (!mounts)
|
|
22
|
+
return map;
|
|
23
|
+
for (const spec of mounts) {
|
|
35
24
|
const separatorIndex = spec.indexOf('=');
|
|
36
|
-
if (separatorIndex
|
|
37
|
-
|
|
38
|
-
throw new TypeError('Invalid --map spec. Use <virtual>=<dir>');
|
|
25
|
+
if (separatorIndex === -1) {
|
|
26
|
+
throw new Error('Incorrect format, expect "<virtual>=<dir>".');
|
|
39
27
|
}
|
|
40
28
|
const virtual = spec.slice(0, separatorIndex);
|
|
41
29
|
const dir = spec.slice(separatorIndex + 1);
|
|
42
|
-
|
|
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
|
-
};
|
|
50
|
-
for (let i = 0; i < argv.length; i++) {
|
|
51
|
-
const arg = argv[i];
|
|
52
|
-
if (arg === '--help'
|
|
53
|
-
|| arg === '-h') {
|
|
54
|
-
return { kind: 'help' };
|
|
55
|
-
}
|
|
56
|
-
if (arg === '--version'
|
|
57
|
-
|| arg === '-v') {
|
|
58
|
-
return { kind: 'version' };
|
|
59
|
-
}
|
|
60
|
-
if (arg === '--root') {
|
|
61
|
-
const value = argv[++i];
|
|
62
|
-
if (!value) {
|
|
63
|
-
throw new TypeError('Missing value for --root');
|
|
64
|
-
}
|
|
65
|
-
options.root = value;
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (arg === '--port') {
|
|
69
|
-
const value = argv[++i];
|
|
70
|
-
if (!value) {
|
|
71
|
-
throw new TypeError('Missing value for --port');
|
|
72
|
-
}
|
|
73
|
-
const parsed = Number(value);
|
|
74
|
-
if (!Number.isFinite(parsed)
|
|
75
|
-
|| parsed <= 0) {
|
|
76
|
-
throw new TypeError('Invalid --port');
|
|
77
|
-
}
|
|
78
|
-
options.port = parsed;
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
if (arg === '--host') {
|
|
82
|
-
const value = argv[++i];
|
|
83
|
-
if (!value) {
|
|
84
|
-
throw new TypeError('Missing value for --host');
|
|
85
|
-
}
|
|
86
|
-
options.host = value;
|
|
87
|
-
continue;
|
|
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
|
-
}
|
|
99
|
-
if (arg.startsWith('-')) {
|
|
100
|
-
throw new TypeError(`Unknown option: ${arg}`);
|
|
101
|
-
}
|
|
102
|
-
// Positional = root directory
|
|
103
|
-
options.root = arg;
|
|
30
|
+
map[virtual] = dir;
|
|
104
31
|
}
|
|
105
|
-
return
|
|
32
|
+
return map;
|
|
106
33
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
34
|
+
const version = readVersion() ?? '';
|
|
35
|
+
yargs(process.argv.slice(2))
|
|
36
|
+
.scriptName('asljs-server')
|
|
37
|
+
.usage('$0 [root] [options]')
|
|
38
|
+
.command('$0 [dir]', 'Start the server', (y) => y
|
|
39
|
+
.positional('dir', { type: 'string',
|
|
40
|
+
describe: 'Root directory to serve (default: .)' })
|
|
41
|
+
.option('root', { type: 'string',
|
|
42
|
+
describe: 'Root directory to serve (default: .)',
|
|
43
|
+
default: '.' })
|
|
44
|
+
.option('port', { type: 'number',
|
|
45
|
+
describe: 'Port to listen on (default: 3000)',
|
|
46
|
+
default: 3000 })
|
|
47
|
+
.option('host', { type: 'string',
|
|
48
|
+
describe: 'Host/interface to bind (default: localhost)',
|
|
49
|
+
default: 'localhost' })
|
|
50
|
+
.option('mount', { type: 'string',
|
|
51
|
+
alias: 'm',
|
|
52
|
+
array: true,
|
|
53
|
+
describe: 'Mount a folder under a virtual path (repeatable). Spec: <virtual>=<dir> (example: assets=../Assets)' }), (argv) => {
|
|
54
|
+
if (!Number.isFinite(argv.port)
|
|
55
|
+
|| argv.port <= 0) {
|
|
56
|
+
throw new Error('Invalid --port');
|
|
121
57
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
58
|
+
if (typeof argv.host !== 'string'
|
|
59
|
+
|| !argv.host) {
|
|
60
|
+
throw new Error('Missing value for --host');
|
|
61
|
+
}
|
|
62
|
+
if (typeof argv.root !== 'string'
|
|
63
|
+
|| !argv.root) {
|
|
64
|
+
throw new Error('Missing value for --root');
|
|
65
|
+
}
|
|
66
|
+
let mounts;
|
|
67
|
+
try {
|
|
68
|
+
mounts =
|
|
69
|
+
parseMounts(argv.mount);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
throw new Error(`Invalid --mount. ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
const options = { root: argv.dir ?? argv.root,
|
|
75
|
+
port: argv.port,
|
|
76
|
+
host: argv.host,
|
|
77
|
+
mounts };
|
|
78
|
+
startServer(options);
|
|
79
|
+
})
|
|
80
|
+
.strict()
|
|
81
|
+
.help('help')
|
|
82
|
+
.alias('help', 'h')
|
|
83
|
+
.version('version', 'Print version', version)
|
|
84
|
+
.alias('version', 'v')
|
|
85
|
+
.exitProcess(false)
|
|
86
|
+
.fail((msg, err, y) => {
|
|
87
|
+
const message = err?.message
|
|
88
|
+
? String(err.message)
|
|
89
|
+
: msg;
|
|
90
|
+
if (message) {
|
|
91
|
+
process.stderr.write(`${message}\n\n`);
|
|
92
|
+
}
|
|
93
|
+
y.showHelp();
|
|
131
94
|
process.exitCode = 1;
|
|
132
|
-
}
|
|
95
|
+
})
|
|
96
|
+
.parse();
|
package/dist/module.d.ts
ADDED
package/dist/module.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { VirtualFolders } from '../virtual-folders.js';
|
|
3
|
+
import type { IRequestHandler } from '../module.js';
|
|
4
|
+
export declare class FilesystemApi implements IRequestHandler {
|
|
5
|
+
readonly filesDir: string;
|
|
6
|
+
readonly virtualFolders: VirtualFolders;
|
|
7
|
+
constructor(filesDir: string, virtualFolders: VirtualFolders);
|
|
8
|
+
tryHandleRequest(request: IncomingMessage, response: ServerResponse, url: URL): Promise<boolean>;
|
|
9
|
+
private fileApiPath;
|
|
10
|
+
private handleFileApi;
|
|
11
|
+
private handleFilesApi;
|
|
12
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as send from '../send.js';
|
|
4
|
+
import { safePath, readBody } from '../receive.js';
|
|
5
|
+
export class FilesystemApi {
|
|
6
|
+
constructor(filesDir, virtualFolders) {
|
|
7
|
+
this.filesDir = filesDir;
|
|
8
|
+
this.virtualFolders = virtualFolders;
|
|
9
|
+
}
|
|
10
|
+
async tryHandleRequest(request, response, url) {
|
|
11
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
12
|
+
// File API: /api/file?path=relative/path.ext
|
|
13
|
+
if (pathname === '/api/file') {
|
|
14
|
+
await this.handleFileApi(request, response, url);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
// Directory listing API: /api/files?path=relative/dir
|
|
18
|
+
if (pathname === '/api/files') {
|
|
19
|
+
await this.handleFilesApi(request, response, url);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
fileApiPath(relativePath) {
|
|
25
|
+
return this.virtualFolders
|
|
26
|
+
.resolve(relativePath)
|
|
27
|
+
.path;
|
|
28
|
+
}
|
|
29
|
+
async handleFileApi(request, response, url) {
|
|
30
|
+
const relativePath = safePath(url.searchParams.get('path')
|
|
31
|
+
|| '');
|
|
32
|
+
if (!relativePath) {
|
|
33
|
+
return send.badRequest(response, 'Invalid "path". Use a relative path with URL-style separators.');
|
|
34
|
+
}
|
|
35
|
+
const filePath = this.fileApiPath(relativePath);
|
|
36
|
+
if (!filePath) {
|
|
37
|
+
return send.forbidden(response);
|
|
38
|
+
}
|
|
39
|
+
if (request.method === 'GET') {
|
|
40
|
+
return send.file(response, filePath);
|
|
41
|
+
}
|
|
42
|
+
if (request.method === 'PUT'
|
|
43
|
+
|| request.method === 'POST') {
|
|
44
|
+
await fsp.mkdir(this.filesDir, { recursive: true });
|
|
45
|
+
try {
|
|
46
|
+
const body = await readBody(request, 1000000);
|
|
47
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
48
|
+
await fsp.writeFile(filePath, body, 'utf8');
|
|
49
|
+
return send.api(response, { path: relativePath });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return send.apiError(response, error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return send.methodNotAllowed(response, 'GET, PUT, POST');
|
|
56
|
+
}
|
|
57
|
+
async handleFilesApi(request, response, url) {
|
|
58
|
+
if (request.method !== 'GET') {
|
|
59
|
+
return send.methodNotAllowed(response, 'GET');
|
|
60
|
+
}
|
|
61
|
+
const relativePath = safePath(url.searchParams.get('path')
|
|
62
|
+
|| '');
|
|
63
|
+
if (relativePath === null) {
|
|
64
|
+
return send.badRequest(response, 'Invalid "path". Use a relative directory path.');
|
|
65
|
+
}
|
|
66
|
+
const directoryPath = this.fileApiPath(relativePath);
|
|
67
|
+
if (!directoryPath) {
|
|
68
|
+
return send.forbidden(response);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const stat = await fsp.stat(directoryPath);
|
|
72
|
+
if (!stat.isDirectory()) {
|
|
73
|
+
return send.badRequest(response, '"path" must point to a directory');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error
|
|
78
|
+
&& typeof error === 'object'
|
|
79
|
+
&& 'code' in error
|
|
80
|
+
&& error.code === 'ENOENT') {
|
|
81
|
+
return send.notFound(response, 'Directory not found');
|
|
82
|
+
}
|
|
83
|
+
return send.error(response, error);
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const entries = await fsp.readdir(directoryPath, { withFileTypes: true });
|
|
87
|
+
const files = entries
|
|
88
|
+
.filter(e => e.isFile())
|
|
89
|
+
.map(e => e.name)
|
|
90
|
+
.sort((a, b) => a.localeCompare(b));
|
|
91
|
+
return send.api(response, { path: relativePath,
|
|
92
|
+
files });
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return send.apiError(response, error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { IRequestHandler } from '../module.js';
|
|
3
|
+
import { VirtualFolders } from '../virtual-folders.js';
|
|
4
|
+
export declare class LiveReloadSupport implements IRequestHandler {
|
|
5
|
+
private readonly clients;
|
|
6
|
+
private readonly headers;
|
|
7
|
+
private readonly RELOAD_SNIPPET;
|
|
8
|
+
private readonly stopWatchers;
|
|
9
|
+
private readonly virtualFolders;
|
|
10
|
+
private reloadPending;
|
|
11
|
+
constructor(virtualFolders: VirtualFolders);
|
|
12
|
+
start(): void;
|
|
13
|
+
stop(): void;
|
|
14
|
+
tryHandleRequest(request: IncomingMessage, response: ServerResponse, url: URL): Promise<boolean>;
|
|
15
|
+
connectClient(request: IncomingMessage, response: ServerResponse): void;
|
|
16
|
+
broadcastReload(): void;
|
|
17
|
+
injectReloadScript(html: string): string;
|
|
18
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
export class LiveReloadSupport {
|
|
3
|
+
constructor(virtualFolders) {
|
|
4
|
+
this.clients = new Set();
|
|
5
|
+
this.headers = { 'content-type': 'text/event-stream',
|
|
6
|
+
'cache-control': 'no-cache',
|
|
7
|
+
'connection': 'keep-alive',
|
|
8
|
+
'x-accel-buffering': 'no' };
|
|
9
|
+
this.RELOAD_SNIPPET = `
|
|
10
|
+
<script>
|
|
11
|
+
try {
|
|
12
|
+
const es = new EventSource('/__events');
|
|
13
|
+
es.addEventListener('reload', () => location.reload());
|
|
14
|
+
} catch (_) {}
|
|
15
|
+
</script>
|
|
16
|
+
`;
|
|
17
|
+
this.stopWatchers = [];
|
|
18
|
+
this.reloadPending = false;
|
|
19
|
+
this.virtualFolders = virtualFolders;
|
|
20
|
+
}
|
|
21
|
+
start() {
|
|
22
|
+
for (const mount of this.virtualFolders.mounts) {
|
|
23
|
+
try {
|
|
24
|
+
this.stopWatchers.push(watchStaticTree(mount.rootDir, () => this.broadcastReload()));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// ignore
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
stop() {
|
|
32
|
+
for (const stop of this.stopWatchers) {
|
|
33
|
+
try {
|
|
34
|
+
stop();
|
|
35
|
+
}
|
|
36
|
+
catch { }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async tryHandleRequest(request, response, url) {
|
|
40
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
41
|
+
if (pathname === '/__events') {
|
|
42
|
+
this.connectClient(request, response);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
connectClient(request, response) {
|
|
48
|
+
response.writeHead(200, this.headers);
|
|
49
|
+
response.write(': connected\n\n');
|
|
50
|
+
this.clients.add(response);
|
|
51
|
+
const ping = setInterval(() => response.write(': ping\n\n'), 30000);
|
|
52
|
+
request.on('close', () => {
|
|
53
|
+
clearInterval(ping);
|
|
54
|
+
this.clients.delete(response);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
broadcastReload() {
|
|
58
|
+
if (this.reloadPending)
|
|
59
|
+
return;
|
|
60
|
+
this.reloadPending = true;
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
this.reloadPending = false;
|
|
63
|
+
for (const res of this.clients) {
|
|
64
|
+
res.write('event: reload\n');
|
|
65
|
+
res.write('data: now\n\n');
|
|
66
|
+
}
|
|
67
|
+
}, 50);
|
|
68
|
+
}
|
|
69
|
+
injectReloadScript(html) {
|
|
70
|
+
const htmlWithReload = html.includes('/__events')
|
|
71
|
+
? html
|
|
72
|
+
: html.replace(/<\/body\s*>/i, m => `${this.RELOAD_SNIPPET}\n${m}`)
|
|
73
|
+
+ (!html.match(/<\/body\s*>/i)
|
|
74
|
+
? `\n${this.RELOAD_SNIPPET}`
|
|
75
|
+
: '');
|
|
76
|
+
return htmlWithReload;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function watchStaticTree(rootDir, onChange) {
|
|
80
|
+
// Best-effort cross-platform watcher.
|
|
81
|
+
// On Windows/macOS, recursive fs.watch works.
|
|
82
|
+
// On Linux, recursive is not supported, but this package is intended for dev-time usage.
|
|
83
|
+
let watcher;
|
|
84
|
+
try {
|
|
85
|
+
watcher =
|
|
86
|
+
fs.watch(rootDir, { recursive: true }, () => onChange());
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
watcher =
|
|
90
|
+
fs.watch(rootDir, () => onChange());
|
|
91
|
+
}
|
|
92
|
+
return () => {
|
|
93
|
+
try {
|
|
94
|
+
watcher.close();
|
|
95
|
+
}
|
|
96
|
+
catch { }
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { LiveReloadSupport } from './live-reload.js';
|
|
3
|
+
import { VirtualFolders } from '../virtual-folders.js';
|
|
4
|
+
import type { IRequestHandler } from '../module.js';
|
|
5
|
+
export declare class StaticFilesRequestHandler implements IRequestHandler {
|
|
6
|
+
private readonly virtualFolders;
|
|
7
|
+
private readonly liveReloadSupport;
|
|
8
|
+
constructor(virtualFolders: VirtualFolders, liveReloadSupport: LiveReloadSupport);
|
|
9
|
+
tryHandleRequest(request: IncomingMessage, response: ServerResponse, url: URL): Promise<boolean>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as send from '../send.js';
|
|
4
|
+
import { safePath } from '../receive.js';
|
|
5
|
+
export class StaticFilesRequestHandler {
|
|
6
|
+
constructor(virtualFolders, liveReloadSupport) {
|
|
7
|
+
this.virtualFolders = virtualFolders;
|
|
8
|
+
this.liveReloadSupport = liveReloadSupport;
|
|
9
|
+
}
|
|
10
|
+
async tryHandleRequest(request, response, url) {
|
|
11
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
12
|
+
const safeUrlPath = safePath(pathname.startsWith('/')
|
|
13
|
+
? pathname.slice(1)
|
|
14
|
+
: pathname);
|
|
15
|
+
if (safeUrlPath === null) {
|
|
16
|
+
send.forbidden(response);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
// Resolve file path
|
|
20
|
+
let filePath = this.virtualFolders
|
|
21
|
+
.resolve(safeUrlPath)
|
|
22
|
+
.path;
|
|
23
|
+
if (!filePath) {
|
|
24
|
+
send.forbidden(response);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
// If path is dir, try index.html
|
|
28
|
+
try {
|
|
29
|
+
const stat = await fsp.stat(filePath);
|
|
30
|
+
if (stat.isDirectory()) {
|
|
31
|
+
filePath =
|
|
32
|
+
path.join(filePath, 'index.html');
|
|
33
|
+
await fsp.stat(filePath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
send.notFound(response);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const ext = path.extname(filePath)
|
|
41
|
+
.toLowerCase();
|
|
42
|
+
// HTML: inject hot-reload snippet
|
|
43
|
+
if (ext === '.html'
|
|
44
|
+
&& request.method === 'GET') {
|
|
45
|
+
try {
|
|
46
|
+
const html = await fsp.readFile(filePath, 'utf8');
|
|
47
|
+
const htmlWithReload = this.liveReloadSupport.injectReloadScript(html);
|
|
48
|
+
send.html(response, htmlWithReload);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
send.error(response, error);
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
send.file(response, filePath);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
export declare function safePath(value: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Reads the body of the given request up to the given size limit.
|
|
5
|
+
*/
|
|
6
|
+
export declare function readBody(request: IncomingMessage, limit: number): Promise<string>;
|
package/dist/receive.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function safePath(value) {
|
|
2
|
+
if (typeof value !== 'string') {
|
|
3
|
+
throw new TypeError('Value must be a string');
|
|
4
|
+
}
|
|
5
|
+
if (value === '') {
|
|
6
|
+
return '';
|
|
7
|
+
}
|
|
8
|
+
const incorrectCharacterMatch = /[^a-zA-Z0-9._/-]/.exec(value);
|
|
9
|
+
if (incorrectCharacterMatch !== null) {
|
|
10
|
+
throw new Error(`Incorrect characters in path at position ${incorrectCharacterMatch.index}.`);
|
|
11
|
+
}
|
|
12
|
+
if (value.startsWith('/')) {
|
|
13
|
+
throw new Error('Path must be relative.');
|
|
14
|
+
}
|
|
15
|
+
const segments = value.split('/');
|
|
16
|
+
for (const segment of segments) {
|
|
17
|
+
if (!segment
|
|
18
|
+
|| segment === '.'
|
|
19
|
+
|| segment === '..') {
|
|
20
|
+
throw new Error('Path must not contain empty, "." or ".." segments.');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reads the body of the given request up to the given size limit.
|
|
27
|
+
*/
|
|
28
|
+
export function readBody(request, limit) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
let size = 0;
|
|
31
|
+
const chunks = [];
|
|
32
|
+
request.on('data', chunk => {
|
|
33
|
+
const buffer = Buffer.isBuffer(chunk)
|
|
34
|
+
? chunk
|
|
35
|
+
: Buffer.from(chunk);
|
|
36
|
+
size += buffer.length;
|
|
37
|
+
if (size > limit) {
|
|
38
|
+
reject(new Error('Payload is too large'));
|
|
39
|
+
request.destroy();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
chunks.push(buffer);
|
|
43
|
+
});
|
|
44
|
+
request.on('end', () => resolve(Buffer.concat(chunks)
|
|
45
|
+
.toString('utf8')));
|
|
46
|
+
request.on('error', reject);
|
|
47
|
+
});
|
|
48
|
+
}
|
package/dist/send.js
CHANGED
|
@@ -36,6 +36,9 @@ const CONTENT_TYPES = new Map([['.html', 'text/html; charset=utf-8'],
|
|
|
36
36
|
['.ico', 'image/x-icon'],
|
|
37
37
|
['.woff', 'font/woff'],
|
|
38
38
|
['.woff2', 'font/woff2']]);
|
|
39
|
+
/**
|
|
40
|
+
* Returns the content type for the specified file path, based on its extension.
|
|
41
|
+
*/
|
|
39
42
|
function contentTypeFor(filePath) {
|
|
40
43
|
const ext = path.extname(filePath)
|
|
41
44
|
.toLowerCase();
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Server } from 'node:http';
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
2
|
export type ServerLogger = {
|
|
3
3
|
log: (...args: unknown[]) => void;
|
|
4
4
|
error: (...args: unknown[]) => void;
|
|
@@ -7,7 +7,21 @@ export type StartServerOptions = {
|
|
|
7
7
|
root?: string;
|
|
8
8
|
port?: number;
|
|
9
9
|
host?: string;
|
|
10
|
+
fsapi?: boolean;
|
|
10
11
|
logger?: ServerLogger;
|
|
11
12
|
mounts?: Record<string, string>;
|
|
12
13
|
};
|
|
13
|
-
export declare
|
|
14
|
+
export declare class Server {
|
|
15
|
+
private readonly root;
|
|
16
|
+
private readonly port;
|
|
17
|
+
private readonly host;
|
|
18
|
+
private readonly logger;
|
|
19
|
+
private readonly filesDir;
|
|
20
|
+
private readonly virtualFolders;
|
|
21
|
+
private readonly requestHandlers;
|
|
22
|
+
private readonly server;
|
|
23
|
+
constructor({ root, port, host, logger, mounts }?: StartServerOptions);
|
|
24
|
+
listen(): HttpServer;
|
|
25
|
+
private serve;
|
|
26
|
+
}
|
|
27
|
+
export declare function startServer(options?: StartServerOptions): HttpServer;
|
package/dist/server.js
CHANGED
|
@@ -1,261 +1,76 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
-
import fsp from 'node:fs/promises';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { URL, pathToFileURL } from 'node:url';
|
|
5
4
|
import * as send from './send.js';
|
|
6
|
-
import { watchStaticTree } from './watch.js';
|
|
7
5
|
import { VirtualFolders } from './virtual-folders.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
const es = new EventSource('/__events');
|
|
37
|
-
es.addEventListener('reload', () => location.reload());
|
|
38
|
-
} catch (_) {}
|
|
39
|
-
</script>
|
|
40
|
-
`;
|
|
41
|
-
const options = { 'access-control-allow-origin': '*',
|
|
42
|
-
'access-control-allow-methods': 'GET,POST,PUT,OPTIONS',
|
|
43
|
-
'access-control-allow-headers': 'content-type' };
|
|
44
|
-
export function startServer({ root = '.', port = 3000, host = 'localhost', logger = console, mounts = {} } = {}) {
|
|
45
|
-
const FILES_DIR = path.resolve(root);
|
|
46
|
-
const virtualFolders = new VirtualFolders(FILES_DIR, mounts);
|
|
47
|
-
// ---------- hot reload (SSE)
|
|
48
|
-
const sseClients = new Set();
|
|
49
|
-
const sseHeaders = { 'content-type': 'text/event-stream',
|
|
50
|
-
'cache-control': 'no-cache',
|
|
51
|
-
'connection': 'keep-alive',
|
|
52
|
-
'x-accel-buffering': 'no' };
|
|
53
|
-
const serveSSE = (req, res) => {
|
|
54
|
-
res.writeHead(200, sseHeaders);
|
|
55
|
-
res.write(': connected\n\n'); // comment = keep-alive
|
|
56
|
-
sseClients.add(res);
|
|
57
|
-
const ping = setInterval(() => res.write(': ping\n\n'), 30000);
|
|
58
|
-
req.on('close', () => {
|
|
59
|
-
clearInterval(ping);
|
|
60
|
-
sseClients.delete(res);
|
|
61
|
-
});
|
|
62
|
-
};
|
|
63
|
-
let reloadPending = false;
|
|
64
|
-
const broadcastReload = () => {
|
|
65
|
-
if (reloadPending)
|
|
66
|
-
return;
|
|
67
|
-
reloadPending = true;
|
|
68
|
-
setTimeout(() => {
|
|
69
|
-
reloadPending = false;
|
|
70
|
-
for (const res of sseClients) {
|
|
71
|
-
res.write('event: reload\n');
|
|
72
|
-
res.write('data: now\n\n');
|
|
73
|
-
}
|
|
74
|
-
}, 50);
|
|
75
|
-
};
|
|
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);
|
|
88
|
-
};
|
|
89
|
-
async function handleFileApi(request, response, url) {
|
|
90
|
-
await fsp.mkdir(FILES_DIR, { recursive: true });
|
|
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
|
-
}
|
|
99
|
-
if (request.method === 'GET') {
|
|
100
|
-
return send.file(response, filePath);
|
|
101
|
-
}
|
|
102
|
-
if (request.method === 'PUT'
|
|
103
|
-
|| request.method === 'POST') {
|
|
104
|
-
try {
|
|
105
|
-
// 1MB limit
|
|
106
|
-
const body = await readBody(request, 1000000);
|
|
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');
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
await fsp.writeFile(filePath, body, 'utf8');
|
|
6
|
+
import { LiveReloadSupport } from './modules/live-reload.js';
|
|
7
|
+
import { FilesystemApi } from './modules/filesystem-api.js';
|
|
8
|
+
import { StaticFilesRequestHandler } from './modules/static-files.js';
|
|
9
|
+
export class Server {
|
|
10
|
+
constructor({ root = '.', port = 3000, host = 'localhost', logger = console, mounts = {} } = {}) {
|
|
11
|
+
this.root = root;
|
|
12
|
+
this.port = port;
|
|
13
|
+
this.host = host;
|
|
14
|
+
this.logger = logger;
|
|
15
|
+
this.filesDir =
|
|
16
|
+
path.resolve(this.root);
|
|
17
|
+
this.virtualFolders =
|
|
18
|
+
new VirtualFolders(this.filesDir, mounts);
|
|
19
|
+
const liveReloadSupport = new LiveReloadSupport(this.virtualFolders);
|
|
20
|
+
const filesystemApi = new FilesystemApi(this.filesDir, this.virtualFolders);
|
|
21
|
+
const staticFilesHandler = new StaticFilesRequestHandler(this.virtualFolders, liveReloadSupport);
|
|
22
|
+
this.requestHandlers =
|
|
23
|
+
[liveReloadSupport,
|
|
24
|
+
filesystemApi,
|
|
25
|
+
staticFilesHandler];
|
|
26
|
+
liveReloadSupport.start();
|
|
27
|
+
this.server =
|
|
28
|
+
http.createServer((request, response) => {
|
|
29
|
+
if (request.method === 'OPTIONS') {
|
|
30
|
+
return send.options(response, { 'access-control-allow-origin': '*',
|
|
31
|
+
'access-control-allow-methods': 'GET,POST,PUT,OPTIONS',
|
|
32
|
+
'access-control-allow-headers': 'content-type' });
|
|
121
33
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
34
|
+
this.logger.log(`${request.method} ${request.url}`);
|
|
35
|
+
this.serve(request, response, new URL(request.url || '/', `http://${request.headers.host || `${this.host}:${this.port}`}`))
|
|
36
|
+
.catch(error => {
|
|
37
|
+
this.logger.error(error);
|
|
38
|
+
send.error(response, error);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
this.server.on('close', () => {
|
|
42
|
+
liveReloadSupport.stop();
|
|
43
|
+
});
|
|
129
44
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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');
|
|
45
|
+
listen() {
|
|
46
|
+
this.server.listen(this.port, this.host, () => {
|
|
47
|
+
this.logger.log(`FILES: ${this.filesDir}`);
|
|
48
|
+
for (const mount of this.virtualFolders.mounts) {
|
|
49
|
+
this.logger.log(`VIRTUAL FOLDER: /${mount.virtual} -> ${mount.rootDir}`);
|
|
147
50
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
}
|
|
51
|
+
this.logger.log(`URL: http://${this.host}:${this.port}`);
|
|
52
|
+
});
|
|
53
|
+
return this.server;
|
|
170
54
|
}
|
|
171
|
-
async
|
|
172
|
-
const
|
|
173
|
-
// SSE endpoint
|
|
174
|
-
if (pathname === '/__events') {
|
|
175
|
-
return serveSSE(request, response);
|
|
176
|
-
}
|
|
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);
|
|
191
|
-
}
|
|
192
|
-
// Resolve file path
|
|
193
|
-
let filePath = safeUrlPath === ''
|
|
194
|
-
? FILES_DIR
|
|
195
|
-
: virtualFolders.toPhysicalPath(safeUrlPath);
|
|
196
|
-
if (!filePath) {
|
|
197
|
-
return send.forbidden(response);
|
|
198
|
-
}
|
|
199
|
-
// If path is dir, try index.html
|
|
200
|
-
try {
|
|
201
|
-
const stat = await fsp.stat(filePath);
|
|
202
|
-
if (stat.isDirectory()) {
|
|
203
|
-
filePath =
|
|
204
|
-
path.join(filePath, 'index.html');
|
|
205
|
-
await fsp.stat(filePath);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
catch {
|
|
209
|
-
return send.notFound(response);
|
|
210
|
-
}
|
|
211
|
-
const ext = path.extname(filePath)
|
|
212
|
-
.toLowerCase();
|
|
213
|
-
// HTML: inject hot-reload snippet
|
|
214
|
-
if (ext === '.html'
|
|
215
|
-
&& request.method === 'GET') {
|
|
55
|
+
async serve(request, response, url) {
|
|
56
|
+
for (const handler of this.requestHandlers) {
|
|
216
57
|
try {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
: html.replace(/<\/body\s*>/i, m => `${RELOAD_SNIPPET}\n${m}`)
|
|
221
|
-
+ (!html.match(/<\/body\s*>/i)
|
|
222
|
-
? `\n${RELOAD_SNIPPET}`
|
|
223
|
-
: '');
|
|
224
|
-
return send.html(response, htmlWithReload);
|
|
58
|
+
if (await handler.tryHandleRequest(request, response, url)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
225
61
|
}
|
|
226
62
|
catch (error) {
|
|
227
|
-
|
|
63
|
+
this.logger.error(error);
|
|
64
|
+
send.error(response, error);
|
|
65
|
+
return;
|
|
228
66
|
}
|
|
229
67
|
}
|
|
230
|
-
|
|
68
|
+
send.notFound(response);
|
|
231
69
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
logger.log(`${request.method} ${request.url}`);
|
|
237
|
-
serveStatic(request, response, new URL(request.url || '/', `http://${request.headers.host || `${host}:${port}`}`))
|
|
238
|
-
.catch(error => {
|
|
239
|
-
logger.error(error);
|
|
240
|
-
send.error(response, error);
|
|
241
|
-
});
|
|
242
|
-
});
|
|
243
|
-
server.on('close', () => {
|
|
244
|
-
for (const stop of stopWatchers) {
|
|
245
|
-
try {
|
|
246
|
-
stop();
|
|
247
|
-
}
|
|
248
|
-
catch { }
|
|
249
|
-
}
|
|
250
|
-
});
|
|
251
|
-
server.listen(port, host, () => {
|
|
252
|
-
logger.log(`FILES: ${FILES_DIR}`);
|
|
253
|
-
for (const mount of virtualFolders.mounts) {
|
|
254
|
-
logger.log(`MAP: /${mount.virtual} -> ${mount.rootDir}`);
|
|
255
|
-
}
|
|
256
|
-
logger.log(`URL: http://${host}:${port}`);
|
|
257
|
-
});
|
|
258
|
-
return server;
|
|
70
|
+
}
|
|
71
|
+
export function startServer(options = {}) {
|
|
72
|
+
return new Server(options)
|
|
73
|
+
.listen();
|
|
259
74
|
}
|
|
260
75
|
const entryFile = process.argv[1];
|
|
261
76
|
if (entryFile
|
|
@@ -9,11 +9,9 @@ export declare class VirtualFolders {
|
|
|
9
9
|
readonly baseDir: string;
|
|
10
10
|
readonly mounts: ReadonlyArray<VirtualFolderMount>;
|
|
11
11
|
constructor(baseDir: string, mounts?: Record<string, string>);
|
|
12
|
-
|
|
13
|
-
getWatchRoots(): ReadonlyArray<string>;
|
|
14
|
-
resolve(relativePath: string): {
|
|
12
|
+
resolve(value: string): {
|
|
15
13
|
rootDir: string;
|
|
16
14
|
relativePath: string;
|
|
15
|
+
path: string;
|
|
17
16
|
};
|
|
18
|
-
toPhysicalPath(relativePath: string): string | null;
|
|
19
17
|
}
|
package/dist/virtual-folders.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { safePath } from './receive.js';
|
|
2
3
|
function isPathInside(child, parent) {
|
|
3
4
|
const childPath = path.resolve(path.normalize(child));
|
|
4
5
|
const parentPath = path.resolve(path.normalize(parent));
|
|
@@ -16,15 +17,16 @@ export class VirtualFolders {
|
|
|
16
17
|
this.mounts =
|
|
17
18
|
Object.entries(mounts)
|
|
18
19
|
.map(([virtual, dir]) => {
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
20
|
+
const safeVirtualPath = safePath(virtual);
|
|
21
|
+
if (safeVirtualPath === null
|
|
22
|
+
|| safeVirtualPath === '') {
|
|
21
23
|
throw new TypeError('Invalid mount virtual path');
|
|
22
24
|
}
|
|
23
25
|
if (typeof dir !== 'string') {
|
|
24
26
|
throw new TypeError('Invalid mount dir');
|
|
25
27
|
}
|
|
26
28
|
return {
|
|
27
|
-
virtual:
|
|
29
|
+
virtual: safeVirtualPath,
|
|
28
30
|
rootDir: path.resolve(path.normalize(dir))
|
|
29
31
|
};
|
|
30
32
|
})
|
|
@@ -32,71 +34,41 @@ export class VirtualFolders {
|
|
|
32
34
|
.sort((a, b) => b.virtual.length
|
|
33
35
|
- a.virtual.length);
|
|
34
36
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
? ''
|
|
40
|
-
: null;
|
|
37
|
+
resolve(value) {
|
|
38
|
+
const safeRelativePath = safePath(value);
|
|
39
|
+
if (safeRelativePath === null) {
|
|
40
|
+
throw new TypeError('Value must be a safe relative path.');
|
|
41
41
|
}
|
|
42
|
-
if (
|
|
43
|
-
return
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
? ''
|
|
47
|
-
: null;
|
|
42
|
+
if (safeRelativePath === '') {
|
|
43
|
+
return { rootDir: this.baseDir,
|
|
44
|
+
relativePath: '',
|
|
45
|
+
path: this.baseDir };
|
|
48
46
|
}
|
|
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
47
|
for (const mount of this.mounts) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
};
|
|
48
|
+
if (safeRelativePath === mount.virtual) {
|
|
49
|
+
return { rootDir: mount.rootDir,
|
|
50
|
+
relativePath: '',
|
|
51
|
+
path: mount.rootDir };
|
|
81
52
|
}
|
|
82
53
|
const prefix = mount.virtual + '/';
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
54
|
+
if (safeRelativePath.startsWith(prefix)) {
|
|
55
|
+
const resolvedRelativePath = safeRelativePath.slice(prefix.length);
|
|
56
|
+
const fullPath = path.join(mount.rootDir, resolvedRelativePath);
|
|
57
|
+
if (!isPathInside(fullPath, mount.rootDir)) {
|
|
58
|
+
throw new Error('Resolved path is outside of virtual folder root.');
|
|
59
|
+
}
|
|
60
|
+
return resolveResult(mount.rootDir, resolvedRelativePath);
|
|
88
61
|
}
|
|
89
62
|
}
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
relativePath
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
: null;
|
|
63
|
+
return resolveResult(this.baseDir, safeRelativePath);
|
|
64
|
+
function resolveResult(baseDir, relativePath) {
|
|
65
|
+
const fullPath = path.join(baseDir, relativePath);
|
|
66
|
+
if (!isPathInside(fullPath, baseDir)) {
|
|
67
|
+
throw new Error('Resolved path is outside of virtual folder root.');
|
|
68
|
+
}
|
|
69
|
+
return { rootDir: baseDir,
|
|
70
|
+
relativePath: relativePath,
|
|
71
|
+
path: fullPath };
|
|
72
|
+
}
|
|
101
73
|
}
|
|
102
74
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "asljs-server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "A lightweight development-time static files server. Supports live-reload. Provides filesystem read-write API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build": "tsc -p .",
|
|
27
|
+
"build:tests": "tsc -p tsconfig.tests.json",
|
|
27
28
|
"lint": "eslint .",
|
|
28
29
|
"lint:fix": "eslint . --fix",
|
|
29
30
|
"prepack": "npm run build",
|
|
30
|
-
"test": "npm run build && node --test",
|
|
31
|
-
"test:watch": "npm run build && node --watch --test",
|
|
32
|
-
"coverage": "npm run build && NODE_V8_COVERAGE=.coverage node --test && node -e \"console.log('Coverage in .coverage (use c8/istanbul if you want reports)')\""
|
|
31
|
+
"test": "npm run build && npm run build:tests && node --test .tests/*.test.js",
|
|
32
|
+
"test:watch": "npm run build && npm run build:tests && node --watch --test .tests/*.test.js",
|
|
33
|
+
"coverage": "npm run build && npm run build:tests && NODE_V8_COVERAGE=.coverage node --test .tests/*.test.js && node -e \"console.log('Coverage in .coverage (use c8/istanbul if you want reports)')\""
|
|
33
34
|
},
|
|
34
35
|
"keywords": [
|
|
35
36
|
"server",
|
|
@@ -45,5 +46,8 @@
|
|
|
45
46
|
"repository": {
|
|
46
47
|
"type": "git",
|
|
47
48
|
"url": "git+https://github.com/AlexandriteSoftware/asljs.git"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"yargs": "^17.7.2"
|
|
48
52
|
}
|
|
49
53
|
}
|