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 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
- function printHelp() {
6
- process.stdout.write(`asljs-server
7
-
8
- Usage:
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
- function parseArgs(argv) {
27
- const options = { root: '.',
28
- port: 3000,
29
- host: 'localhost' };
30
- for (let i = 0; i < argv.length; i++) {
31
- const arg = argv[i];
32
- if (arg === '--help'
33
- || arg === '-h') {
34
- return { kind: 'help' };
35
- }
36
- if (arg === '--version'
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
- if (arg === '--host') {
62
- const value = argv[++i];
63
- if (!value) {
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 { kind: 'run', options };
32
+ return map;
76
33
  }
77
- try {
78
- const parsed = parseArgs(process.argv.slice(2));
79
- switch (parsed.kind) {
80
- case 'help':
81
- printHelp();
82
- process.exitCode = 0;
83
- break;
84
- case 'version':
85
- process.stdout.write(`${readVersion() ?? ''}\n`);
86
- process.exitCode = 0;
87
- break;
88
- default:
89
- startServer(parsed.options);
90
- break;
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
- catch (error) {
94
- const message = (error
95
- && typeof error === 'object'
96
- && 'message' in error)
97
- ? String(error.message)
98
- : String(error);
99
- process.stderr.write(`${message}\n\n`);
100
- printHelp();
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();
@@ -0,0 +1,5 @@
1
+ import { URL } from 'node:url';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ export interface IRequestHandler {
4
+ tryHandleRequest(request: IncomingMessage, response: ServerResponse, url: URL): Promise<boolean>;
5
+ }
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>;
@@ -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 function startServer({ root, port, host, logger }?: StartServerOptions): Server;
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 { watchStaticTree } from './watch.js';
7
- function isPathInside(child, parent) {
8
- const resolved = path.resolve(child);
9
- return resolved === parent
10
- || resolved.startsWith(parent + path.sep);
11
- }
12
- // inject minimal client into served HTML
13
- const RELOAD_SNIPPET = `<script>
14
- try {
15
- const es = new EventSource('/__events');
16
- es.addEventListener('reload', () => location.reload());
17
- } catch (_) {}
18
- </script>`;
19
- const safeJsonName = (name) => typeof name === 'string'
20
- && /^[a-zA-Z0-9._/-]+$/.test(name)
21
- ? name
22
- : null;
23
- const safeRelativeApiPath = (value, { allowEmpty = false } = {}) => {
24
- if (value === null
25
- || value === undefined) {
26
- return allowEmpty
27
- ? ''
28
- : null;
29
- }
30
- if (typeof value !== 'string')
31
- return null;
32
- if (value === '')
33
- return allowEmpty
34
- ? ''
35
- : null;
36
- if (!/^[a-zA-Z0-9._/-]+$/.test(value))
37
- return null;
38
- // Force URL-style separators.
39
- if (value.includes('\\'))
40
- return null;
41
- if (value.startsWith('/'))
42
- return null;
43
- const parts = value.split('/');
44
- for (const part of parts) {
45
- if (!part
46
- || part === '.'
47
- || part === '..') {
48
- return null;
49
- }
50
- }
51
- return value;
52
- };
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
- else {
147
- await fsp.writeFile(filePath, body, 'utf8');
148
- }
149
- return send.api(response, { path: relativePath });
150
- }
151
- catch (error) {
152
- return send.apiError(response, error);
153
- }
154
- }
155
- return send.methodNotAllowed(response, 'GET, PUT, POST');
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
- async function handleFilesApi(request, response, url) {
158
- if (request.method !== 'GET') {
159
- return send.methodNotAllowed(response, 'GET');
160
- }
161
- await fsp.mkdir(FILES_DIR, { recursive: true });
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
- catch (error) {
177
- if (error
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 function serveStatic(request, response, url) {
199
- const pathname = decodeURIComponent(url.pathname);
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
- const html = await fsp.readFile(filePath, 'utf8');
236
- const htmlWithReload = html.includes('/__events')
237
- ? html
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
- return send.error(response, error);
63
+ this.logger.error(error);
64
+ send.error(response, error);
65
+ return;
246
66
  }
247
67
  }
248
- return send.file(response, filePath);
68
+ send.notFound(response);
249
69
  }
250
- const server = http.createServer((request, response) => {
251
- if (request.method === 'OPTIONS') {
252
- return send.options(response, options);
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.1",
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
  }