asljs-server 0.2.1 → 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 +8 -0
- package/dist/cli.js +80 -86
- 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.d.ts +52 -0
- package/dist/send.js +57 -0
- package/dist/server.d.ts +17 -2
- package/dist/server.js +59 -251
- package/dist/virtual-folders.d.ts +17 -0
- package/dist/virtual-folders.js +74 -0
- package/package.json +8 -4
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 --mount 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 `--mount 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
|
@@ -2,20 +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>]
|
|
10
|
-
|
|
11
|
-
Options:
|
|
12
|
-
--root <dir> Root directory to serve (default: .)
|
|
13
|
-
--port <number> Port to listen on (default: 3000)
|
|
14
|
-
--host <name> Host/interface to bind (default: localhost)
|
|
15
|
-
--help Show this help
|
|
16
|
-
--version Print version
|
|
17
|
-
`);
|
|
18
|
-
}
|
|
5
|
+
import yargs from 'yargs';
|
|
6
|
+
/**
|
|
7
|
+
* Reads the asljs-server version from package.json.
|
|
8
|
+
*/
|
|
19
9
|
function readVersion() {
|
|
20
10
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
21
11
|
const packageJsonPath = path.join(here, '..', 'package.json');
|
|
@@ -23,80 +13,84 @@ function readVersion() {
|
|
|
23
13
|
return JSON.parse(raw)
|
|
24
14
|
.version;
|
|
25
15
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|| arg === '-v') {
|
|
38
|
-
return { kind: 'version' };
|
|
39
|
-
}
|
|
40
|
-
if (arg === '--root') {
|
|
41
|
-
const value = argv[++i];
|
|
42
|
-
if (!value) {
|
|
43
|
-
throw new TypeError('Missing value for --root');
|
|
44
|
-
}
|
|
45
|
-
options.root = value;
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (arg === '--port') {
|
|
49
|
-
const value = argv[++i];
|
|
50
|
-
if (!value) {
|
|
51
|
-
throw new TypeError('Missing value for --port');
|
|
52
|
-
}
|
|
53
|
-
const parsed = Number(value);
|
|
54
|
-
if (!Number.isFinite(parsed)
|
|
55
|
-
|| parsed <= 0) {
|
|
56
|
-
throw new TypeError('Invalid --port');
|
|
57
|
-
}
|
|
58
|
-
options.port = parsed;
|
|
59
|
-
continue;
|
|
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) {
|
|
24
|
+
const separatorIndex = spec.indexOf('=');
|
|
25
|
+
if (separatorIndex === -1) {
|
|
26
|
+
throw new Error('Incorrect format, expect "<virtual>=<dir>".');
|
|
60
27
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
throw new TypeError('Missing value for --host');
|
|
65
|
-
}
|
|
66
|
-
options.host = value;
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
if (arg.startsWith('-')) {
|
|
70
|
-
throw new TypeError(`Unknown option: ${arg}`);
|
|
71
|
-
}
|
|
72
|
-
// Positional = root directory
|
|
73
|
-
options.root = arg;
|
|
28
|
+
const virtual = spec.slice(0, separatorIndex);
|
|
29
|
+
const dir = spec.slice(separatorIndex + 1);
|
|
30
|
+
map[virtual] = dir;
|
|
74
31
|
}
|
|
75
|
-
return
|
|
32
|
+
return map;
|
|
76
33
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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');
|
|
91
57
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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();
|
|
101
94
|
process.exitCode = 1;
|
|
102
|
-
}
|
|
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.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'],
|
|
@@ -17,20 +36,33 @@ const CONTENT_TYPES = new Map([['.html', 'text/html; charset=utf-8'],
|
|
|
17
36
|
['.ico', 'image/x-icon'],
|
|
18
37
|
['.woff', 'font/woff'],
|
|
19
38
|
['.woff2', 'font/woff2']]);
|
|
39
|
+
/**
|
|
40
|
+
* Returns the content type for the specified file path, based on its extension.
|
|
41
|
+
*/
|
|
20
42
|
function contentTypeFor(filePath) {
|
|
21
43
|
const ext = path.extname(filePath)
|
|
22
44
|
.toLowerCase();
|
|
23
45
|
return CONTENT_TYPES.get(ext)
|
|
24
46
|
|| 'application/octet-stream';
|
|
25
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Sends an empty response to the client for an OPTIONS request.
|
|
50
|
+
* Typically used for CORS preflight requests.
|
|
51
|
+
*/
|
|
26
52
|
export function options(response, headers) {
|
|
27
53
|
response.writeHead(204, headers);
|
|
28
54
|
response.end();
|
|
29
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Sends a JSON API response with status 200.
|
|
58
|
+
*/
|
|
30
59
|
export function api(response, payload) {
|
|
31
60
|
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
32
61
|
response.end(JSON.stringify(payload));
|
|
33
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Sends an API error message to the response with status 500.
|
|
65
|
+
*/
|
|
34
66
|
export function apiError(response, error) {
|
|
35
67
|
response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
|
|
36
68
|
const message = (error && typeof error === 'object' && 'message' in error)
|
|
@@ -38,33 +70,54 @@ export function apiError(response, error) {
|
|
|
38
70
|
: String(error);
|
|
39
71
|
response.end(JSON.stringify({ error: message }));
|
|
40
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Sends a JSON string to the response with the specified status.
|
|
75
|
+
*/
|
|
41
76
|
export function json(response, status, jsonString) {
|
|
42
77
|
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
43
78
|
response.end(typeof jsonString === 'string'
|
|
44
79
|
? jsonString
|
|
45
80
|
: JSON.stringify(jsonString));
|
|
46
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Sends an HTML string to the response with status 200.
|
|
84
|
+
*/
|
|
47
85
|
export function html(response, htmlString) {
|
|
48
86
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
49
87
|
response.end(htmlString);
|
|
50
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Sends a "Bad request" error message to the response with status 400.
|
|
91
|
+
*/
|
|
51
92
|
export function badRequest(response, message = 'Bad request') {
|
|
52
93
|
response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
53
94
|
response.end(message);
|
|
54
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Sends a "Forbidden" error message to the response with status 403.
|
|
98
|
+
*/
|
|
55
99
|
export function forbidden(response, message = 'Forbidden') {
|
|
56
100
|
response.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
57
101
|
response.end(message);
|
|
58
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Sends a "Not found" error message to the response with status 404.
|
|
105
|
+
*/
|
|
59
106
|
export function notFound(response, message = 'Not found') {
|
|
60
107
|
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
61
108
|
response.end(message);
|
|
62
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Sends a "Method not allowed" error message to the response with status 405.
|
|
112
|
+
*/
|
|
63
113
|
export function methodNotAllowed(response, allowed = 'GET') {
|
|
64
114
|
response.writeHead(405, { 'content-type': 'text/plain; charset=utf-8',
|
|
65
115
|
'allow': allowed });
|
|
66
116
|
response.end('Method not allowed');
|
|
67
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Sends an error message to the response with status 500.
|
|
120
|
+
*/
|
|
68
121
|
export function error(response, err) {
|
|
69
122
|
response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
70
123
|
const message = (err && typeof err === 'object' && 'stack' in err)
|
|
@@ -74,6 +127,10 @@ export function error(response, err) {
|
|
|
74
127
|
: String(err);
|
|
75
128
|
response.end(message);
|
|
76
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Sends a static file, specified by `filePath`, to the response,
|
|
132
|
+
* asynchronously.
|
|
133
|
+
*/
|
|
77
134
|
export async function file(response, filePath) {
|
|
78
135
|
try {
|
|
79
136
|
const stat = await fsp.stat(filePath);
|
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,6 +7,21 @@ export type StartServerOptions = {
|
|
|
7
7
|
root?: string;
|
|
8
8
|
port?: number;
|
|
9
9
|
host?: string;
|
|
10
|
+
fsapi?: boolean;
|
|
10
11
|
logger?: ServerLogger;
|
|
12
|
+
mounts?: Record<string, string>;
|
|
11
13
|
};
|
|
12
|
-
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,268 +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 {
|
|
7
|
-
|
|
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
|
-
: 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
|
-
};
|
|
53
|
-
function readBody(request, limit) {
|
|
54
|
-
return new Promise((resolve, reject) => {
|
|
55
|
-
let size = 0;
|
|
56
|
-
const chunks = [];
|
|
57
|
-
request.on('data', chunk => {
|
|
58
|
-
const buffer = Buffer.isBuffer(chunk)
|
|
59
|
-
? chunk
|
|
60
|
-
: Buffer.from(chunk);
|
|
61
|
-
size += buffer.length;
|
|
62
|
-
if (size > limit) {
|
|
63
|
-
reject(new Error('Payload too large'));
|
|
64
|
-
request.destroy();
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
chunks.push(buffer);
|
|
68
|
-
});
|
|
69
|
-
request.on('end', () => resolve(Buffer.concat(chunks)
|
|
70
|
-
.toString('utf8')));
|
|
71
|
-
request.on('error', reject);
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
const options = { 'access-control-allow-origin': '*',
|
|
75
|
-
'access-control-allow-methods': 'GET,POST,PUT,OPTIONS',
|
|
76
|
-
'access-control-allow-headers': 'content-type' };
|
|
77
|
-
export function startServer({ root = '.', port = 3000, host = 'localhost', logger = console } = {}) {
|
|
78
|
-
const FILES_DIR = path.resolve(root);
|
|
79
|
-
// ---------- hot reload (SSE)
|
|
80
|
-
const sseClients = new Set();
|
|
81
|
-
const sseHeaders = { 'content-type': 'text/event-stream',
|
|
82
|
-
'cache-control': 'no-cache',
|
|
83
|
-
'connection': 'keep-alive',
|
|
84
|
-
'x-accel-buffering': 'no' };
|
|
85
|
-
const serveSSE = (req, res) => {
|
|
86
|
-
res.writeHead(200, sseHeaders);
|
|
87
|
-
res.write(': connected\n\n'); // comment = keep-alive
|
|
88
|
-
sseClients.add(res);
|
|
89
|
-
const ping = setInterval(() => res.write(': ping\n\n'), 30000);
|
|
90
|
-
req.on('close', () => {
|
|
91
|
-
clearInterval(ping);
|
|
92
|
-
sseClients.delete(res);
|
|
93
|
-
});
|
|
94
|
-
};
|
|
95
|
-
let reloadPending = false;
|
|
96
|
-
const broadcastReload = () => {
|
|
97
|
-
if (reloadPending)
|
|
98
|
-
return;
|
|
99
|
-
reloadPending = true;
|
|
100
|
-
setTimeout(() => {
|
|
101
|
-
reloadPending = false;
|
|
102
|
-
for (const res of sseClients) {
|
|
103
|
-
res.write('event: reload\n');
|
|
104
|
-
res.write('data: now\n\n');
|
|
105
|
-
}
|
|
106
|
-
}, 50);
|
|
107
|
-
};
|
|
108
|
-
watchStaticTree(FILES_DIR, () => broadcastReload());
|
|
109
|
-
// ---------- File API helpers
|
|
110
|
-
const fileApiPath = (relativePath) => {
|
|
111
|
-
const full = path.join(FILES_DIR, relativePath);
|
|
112
|
-
return isPathInside(full, FILES_DIR)
|
|
113
|
-
? full
|
|
114
|
-
: null;
|
|
115
|
-
};
|
|
116
|
-
async function handleFileApi(request, response, url) {
|
|
117
|
-
await fsp.mkdir(FILES_DIR, { recursive: true });
|
|
118
|
-
const relativePath = safeRelativeApiPath(url.searchParams.get('path'));
|
|
119
|
-
if (!relativePath) {
|
|
120
|
-
return send.badRequest(response, 'Invalid "path". Use a relative path with URL-style separators.');
|
|
121
|
-
}
|
|
122
|
-
const filePath = fileApiPath(relativePath);
|
|
123
|
-
if (!filePath) {
|
|
124
|
-
return send.forbidden(response);
|
|
125
|
-
}
|
|
126
|
-
if (request.method === 'GET') {
|
|
127
|
-
return send.file(response, filePath);
|
|
128
|
-
}
|
|
129
|
-
if (request.method === 'PUT'
|
|
130
|
-
|| request.method === 'POST') {
|
|
131
|
-
try {
|
|
132
|
-
// 1MB limit
|
|
133
|
-
const body = await readBody(request, 1000000);
|
|
134
|
-
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
135
|
-
// Preserve the old behavior for JSON files.
|
|
136
|
-
if (path.extname(filePath).toLowerCase() === '.json') {
|
|
137
|
-
let parsed;
|
|
138
|
-
try {
|
|
139
|
-
parsed = JSON.parse(body);
|
|
140
|
-
}
|
|
141
|
-
catch {
|
|
142
|
-
return send.badRequest(response, 'Invalid JSON');
|
|
143
|
-
}
|
|
144
|
-
await fsp.writeFile(filePath, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
|
|
5
|
+
import { VirtualFolders } from './virtual-folders.js';
|
|
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' });
|
|
145
33
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
});
|
|
156
44
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const relativePath = safeRelativeApiPath(url.searchParams.get('path'), { allowEmpty: true });
|
|
163
|
-
if (relativePath === null) {
|
|
164
|
-
return send.badRequest(response, 'Invalid "path". Use a relative directory path.');
|
|
165
|
-
}
|
|
166
|
-
const directoryPath = fileApiPath(relativePath);
|
|
167
|
-
if (!directoryPath) {
|
|
168
|
-
return send.forbidden(response);
|
|
169
|
-
}
|
|
170
|
-
try {
|
|
171
|
-
const stat = await fsp.stat(directoryPath);
|
|
172
|
-
if (!stat.isDirectory()) {
|
|
173
|
-
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}`);
|
|
174
50
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
&& typeof error === 'object'
|
|
179
|
-
&& 'code' in error
|
|
180
|
-
&& error.code === 'ENOENT') {
|
|
181
|
-
return send.notFound(response, 'Directory not found');
|
|
182
|
-
}
|
|
183
|
-
return send.error(response, error);
|
|
184
|
-
}
|
|
185
|
-
try {
|
|
186
|
-
const entries = await fsp.readdir(directoryPath, { withFileTypes: true });
|
|
187
|
-
const files = entries
|
|
188
|
-
.filter(e => e.isFile())
|
|
189
|
-
.map(e => e.name)
|
|
190
|
-
.sort((a, b) => a.localeCompare(b));
|
|
191
|
-
return send.api(response, { path: relativePath,
|
|
192
|
-
files });
|
|
193
|
-
}
|
|
194
|
-
catch (error) {
|
|
195
|
-
return send.apiError(response, error);
|
|
196
|
-
}
|
|
51
|
+
this.logger.log(`URL: http://${this.host}:${this.port}`);
|
|
52
|
+
});
|
|
53
|
+
return this.server;
|
|
197
54
|
}
|
|
198
|
-
async
|
|
199
|
-
const
|
|
200
|
-
// SSE endpoint
|
|
201
|
-
if (pathname === '/__events') {
|
|
202
|
-
return serveSSE(request, response);
|
|
203
|
-
}
|
|
204
|
-
// File API: /api/file?path=relative/path.ext
|
|
205
|
-
if (pathname === '/api/file') {
|
|
206
|
-
return handleFileApi(request, response, url);
|
|
207
|
-
}
|
|
208
|
-
// Directory listing API: /api/files?path=relative/dir
|
|
209
|
-
if (pathname === '/api/files') {
|
|
210
|
-
return handleFilesApi(request, response, url);
|
|
211
|
-
}
|
|
212
|
-
// Resolve file path
|
|
213
|
-
let filePath = path.normalize(path.join(FILES_DIR, pathname));
|
|
214
|
-
if (!isPathInside(filePath, FILES_DIR)) {
|
|
215
|
-
return send.forbidden(response);
|
|
216
|
-
}
|
|
217
|
-
// If path is dir, try index.html
|
|
218
|
-
try {
|
|
219
|
-
const stat = await fsp.stat(filePath);
|
|
220
|
-
if (stat.isDirectory()) {
|
|
221
|
-
filePath =
|
|
222
|
-
path.join(filePath, 'index.html');
|
|
223
|
-
await fsp.stat(filePath);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
catch {
|
|
227
|
-
return send.notFound(response);
|
|
228
|
-
}
|
|
229
|
-
const ext = path.extname(filePath)
|
|
230
|
-
.toLowerCase();
|
|
231
|
-
// HTML: inject hot-reload snippet
|
|
232
|
-
if (ext === '.html'
|
|
233
|
-
&& request.method === 'GET') {
|
|
55
|
+
async serve(request, response, url) {
|
|
56
|
+
for (const handler of this.requestHandlers) {
|
|
234
57
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
: html.replace(/<\/body\s*>/i, m => `${RELOAD_SNIPPET}\n${m}`) +
|
|
239
|
-
(!html.match(/<\/body\s*>/i)
|
|
240
|
-
? `\n${RELOAD_SNIPPET}`
|
|
241
|
-
: '');
|
|
242
|
-
return send.html(response, htmlWithReload);
|
|
58
|
+
if (await handler.tryHandleRequest(request, response, url)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
243
61
|
}
|
|
244
62
|
catch (error) {
|
|
245
|
-
|
|
63
|
+
this.logger.error(error);
|
|
64
|
+
send.error(response, error);
|
|
65
|
+
return;
|
|
246
66
|
}
|
|
247
67
|
}
|
|
248
|
-
|
|
68
|
+
send.notFound(response);
|
|
249
69
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
logger.log(`${request.method} ${request.url}`);
|
|
255
|
-
serveStatic(request, response, new URL(request.url || '/', `http://${request.headers.host || `${host}:${port}`}`))
|
|
256
|
-
.catch(error => {
|
|
257
|
-
logger.error(error);
|
|
258
|
-
send.error(response, error);
|
|
259
|
-
});
|
|
260
|
-
});
|
|
261
|
-
server.listen(port, host, () => {
|
|
262
|
-
logger.log(`FILES: ${FILES_DIR}`);
|
|
263
|
-
logger.log(`URL: http://${host}:${port}`);
|
|
264
|
-
});
|
|
265
|
-
return server;
|
|
70
|
+
}
|
|
71
|
+
export function startServer(options = {}) {
|
|
72
|
+
return new Server(options)
|
|
73
|
+
.listen();
|
|
266
74
|
}
|
|
267
75
|
const entryFile = process.argv[1];
|
|
268
76
|
if (entryFile
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
resolve(value: string): {
|
|
13
|
+
rootDir: string;
|
|
14
|
+
relativePath: string;
|
|
15
|
+
path: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { safePath } from './receive.js';
|
|
3
|
+
function isPathInside(child, parent) {
|
|
4
|
+
const childPath = path.resolve(path.normalize(child));
|
|
5
|
+
const parentPath = path.resolve(path.normalize(parent));
|
|
6
|
+
return childPath === parentPath
|
|
7
|
+
|| childPath.startsWith(parentPath + path.sep);
|
|
8
|
+
}
|
|
9
|
+
export class VirtualFolders {
|
|
10
|
+
constructor(baseDir, mounts = {}) {
|
|
11
|
+
if (!baseDir
|
|
12
|
+
|| typeof baseDir !== 'string') {
|
|
13
|
+
throw new TypeError('VirtualFolders: baseDir must be a string');
|
|
14
|
+
}
|
|
15
|
+
this.baseDir =
|
|
16
|
+
path.resolve(path.normalize(baseDir));
|
|
17
|
+
this.mounts =
|
|
18
|
+
Object.entries(mounts)
|
|
19
|
+
.map(([virtual, dir]) => {
|
|
20
|
+
const safeVirtualPath = safePath(virtual);
|
|
21
|
+
if (safeVirtualPath === null
|
|
22
|
+
|| safeVirtualPath === '') {
|
|
23
|
+
throw new TypeError('Invalid mount virtual path');
|
|
24
|
+
}
|
|
25
|
+
if (typeof dir !== 'string') {
|
|
26
|
+
throw new TypeError('Invalid mount dir');
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
virtual: safeVirtualPath,
|
|
30
|
+
rootDir: path.resolve(path.normalize(dir))
|
|
31
|
+
};
|
|
32
|
+
})
|
|
33
|
+
// Prefer longer (more specific) virtual paths.
|
|
34
|
+
.sort((a, b) => b.virtual.length
|
|
35
|
+
- a.virtual.length);
|
|
36
|
+
}
|
|
37
|
+
resolve(value) {
|
|
38
|
+
const safeRelativePath = safePath(value);
|
|
39
|
+
if (safeRelativePath === null) {
|
|
40
|
+
throw new TypeError('Value must be a safe relative path.');
|
|
41
|
+
}
|
|
42
|
+
if (safeRelativePath === '') {
|
|
43
|
+
return { rootDir: this.baseDir,
|
|
44
|
+
relativePath: '',
|
|
45
|
+
path: this.baseDir };
|
|
46
|
+
}
|
|
47
|
+
for (const mount of this.mounts) {
|
|
48
|
+
if (safeRelativePath === mount.virtual) {
|
|
49
|
+
return { rootDir: mount.rootDir,
|
|
50
|
+
relativePath: '',
|
|
51
|
+
path: mount.rootDir };
|
|
52
|
+
}
|
|
53
|
+
const prefix = mount.virtual + '/';
|
|
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);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
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
|
+
}
|
|
73
|
+
}
|
|
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
|
}
|