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 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 --map assets=../Assets`
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 `--map assets=../Assets`:
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
- function printHelp() {
6
- process.stdout.write(`asljs-server
7
-
8
- Usage:
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
- function parseArgs(argv) {
29
- const options = { root: '.',
30
- port: 3000,
31
- host: 'localhost',
32
- mounts: {} };
33
- const addMount = (spec) => {
34
- // Use '=' to avoid Windows drive-letter ambiguity.
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 <= 0
37
- || separatorIndex >= spec.length - 1) {
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
- if (!/^[a-zA-Z0-9._/-]+$/.test(virtual)
43
- || virtual.startsWith('/')
44
- || virtual.includes('\\')
45
- || virtual.split('/').some(p => !p || p === '.' || p === '..')) {
46
- throw new TypeError('Invalid virtual path in --map spec');
47
- }
48
- options.mounts[virtual] = dir;
49
- };
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 { kind: 'run', options };
32
+ return map;
106
33
  }
107
- try {
108
- const parsed = parseArgs(process.argv.slice(2));
109
- switch (parsed.kind) {
110
- case 'help':
111
- printHelp();
112
- process.exitCode = 0;
113
- break;
114
- case 'version':
115
- process.stdout.write(`${readVersion() ?? ''}\n`);
116
- process.exitCode = 0;
117
- break;
118
- default:
119
- startServer(parsed.options);
120
- 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');
121
57
  }
122
- }
123
- catch (error) {
124
- const message = (error
125
- && typeof error === 'object'
126
- && 'message' in error)
127
- ? String(error.message)
128
- : String(error);
129
- process.stderr.write(`${message}\n\n`);
130
- 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();
131
94
  process.exitCode = 1;
132
- }
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.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 function startServer({ root, port, host, logger, mounts }?: 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,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
- * Reads the body of the given request up to the given size limit.
10
- */
11
- function readBody(request, limit) {
12
- return new Promise((resolve, reject) => {
13
- let size = 0;
14
- const chunks = [];
15
- request.on('data', chunk => {
16
- const buffer = Buffer.isBuffer(chunk)
17
- ? chunk
18
- : Buffer.from(chunk);
19
- size += buffer.length;
20
- if (size > limit) {
21
- reject(new Error('Payload too large'));
22
- request.destroy();
23
- return;
24
- }
25
- chunks.push(buffer);
26
- });
27
- request.on('end', () => resolve(Buffer.concat(chunks)
28
- .toString('utf8')));
29
- request.on('error', reject);
30
- });
31
- }
32
- // inject minimal client into served HTML
33
- const RELOAD_SNIPPET = `
34
- <script>
35
- try {
36
- const es = new EventSource('/__events');
37
- es.addEventListener('reload', () => location.reload());
38
- } catch (_) {}
39
- </script>
40
- `;
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
- return send.api(response, { path: relativePath });
123
- }
124
- catch (error) {
125
- return send.apiError(response, error);
126
- }
127
- }
128
- 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
+ });
129
44
  }
130
- async function handleFilesApi(request, response, url) {
131
- if (request.method !== 'GET') {
132
- return send.methodNotAllowed(response, 'GET');
133
- }
134
- await fsp.mkdir(FILES_DIR, { recursive: true });
135
- const relativePath = VirtualFolders.safeRelativePath(url.searchParams.get('path'), { allowEmpty: true });
136
- if (relativePath === null) {
137
- return send.badRequest(response, 'Invalid "path". Use a relative directory path.');
138
- }
139
- const directoryPath = fileApiPath(relativePath);
140
- if (!directoryPath) {
141
- return send.forbidden(response);
142
- }
143
- try {
144
- const stat = await fsp.stat(directoryPath);
145
- if (!stat.isDirectory()) {
146
- return send.badRequest(response, '"path" must point to a directory');
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
- catch (error) {
150
- if (error
151
- && typeof error === 'object'
152
- && 'code' in error
153
- && error.code === 'ENOENT') {
154
- return send.notFound(response, 'Directory not found');
155
- }
156
- return send.error(response, error);
157
- }
158
- try {
159
- const entries = await fsp.readdir(directoryPath, { withFileTypes: true });
160
- const files = entries
161
- .filter(e => e.isFile())
162
- .map(e => e.name)
163
- .sort((a, b) => a.localeCompare(b));
164
- return send.api(response, { path: relativePath,
165
- files });
166
- }
167
- catch (error) {
168
- return send.apiError(response, error);
169
- }
51
+ this.logger.log(`URL: http://${this.host}:${this.port}`);
52
+ });
53
+ return this.server;
170
54
  }
171
- async function serveStatic(request, response, url) {
172
- const pathname = decodeURIComponent(url.pathname);
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
- const html = await fsp.readFile(filePath, 'utf8');
218
- const htmlWithReload = html.includes('/__events')
219
- ? html
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
- return send.error(response, error);
63
+ this.logger.error(error);
64
+ send.error(response, error);
65
+ return;
228
66
  }
229
67
  }
230
- return send.file(response, filePath);
68
+ send.notFound(response);
231
69
  }
232
- const server = http.createServer((request, response) => {
233
- if (request.method === 'OPTIONS') {
234
- return send.options(response, options);
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
- static safeRelativePath(value: string | null | undefined, { allowEmpty }?: VerifySafeRelativePathOptions): string | null;
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
  }
@@ -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 safeVirtual = VirtualFolders.safeRelativePath(virtual);
20
- if (!safeVirtual) {
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: safeVirtual,
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
- static safeRelativePath(value, { allowEmpty = false } = {}) {
36
- if (value === null
37
- || value === undefined) {
38
- return allowEmpty
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 (typeof value !== 'string')
43
- return null;
44
- if (value === '') {
45
- return allowEmpty
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
- set.add(mount.rootDir);
71
- }
72
- return Array.from(set);
73
- }
74
- resolve(relativePath) {
75
- for (const mount of this.mounts) {
76
- if (relativePath === mount.virtual) {
77
- return {
78
- rootDir: mount.rootDir,
79
- relativePath: ''
80
- };
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 (relativePath.startsWith(prefix)) {
84
- return {
85
- rootDir: mount.rootDir,
86
- relativePath: relativePath.slice(prefix.length)
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
- rootDir: this.baseDir,
92
- relativePath
93
- };
94
- }
95
- toPhysicalPath(relativePath) {
96
- const resolved = this.resolve(relativePath);
97
- const full = path.join(resolved.rootDir, resolved.relativePath);
98
- return isPathInside(full, resolved.rootDir)
99
- ? full
100
- : null;
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.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
  }