asljs-server 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 --map 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 `--map 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
@@ -6,10 +6,12 @@ function printHelp() {
6
6
  process.stdout.write(`asljs-server
7
7
 
8
8
  Usage:
9
- asljs-server [--root <dir>] [--port <number>] [--host <name>]
9
+ asljs-server [--root <dir>] [--port <number>] [--host <name>] [--map <virtual>=<dir>]
10
10
 
11
11
  Options:
12
12
  --root <dir> Root directory to serve (default: .)
13
+ --map <spec> Map a folder under a virtual path (repeatable)
14
+ Spec: <virtual>=<dir> (example: assets=../Assets)
13
15
  --port <number> Port to listen on (default: 3000)
14
16
  --host <name> Host/interface to bind (default: localhost)
15
17
  --help Show this help
@@ -26,7 +28,25 @@ function readVersion() {
26
28
  function parseArgs(argv) {
27
29
  const options = { root: '.',
28
30
  port: 3000,
29
- host: 'localhost' };
31
+ host: 'localhost',
32
+ mounts: {} };
33
+ const addMount = (spec) => {
34
+ // Use '=' to avoid Windows drive-letter ambiguity.
35
+ const separatorIndex = spec.indexOf('=');
36
+ if (separatorIndex <= 0
37
+ || separatorIndex >= spec.length - 1) {
38
+ throw new TypeError('Invalid --map spec. Use <virtual>=<dir>');
39
+ }
40
+ const virtual = spec.slice(0, separatorIndex);
41
+ const dir = spec.slice(separatorIndex + 1);
42
+ if (!/^[a-zA-Z0-9._/-]+$/.test(virtual)
43
+ || virtual.startsWith('/')
44
+ || virtual.includes('\\')
45
+ || virtual.split('/').some(p => !p || p === '.' || p === '..')) {
46
+ throw new TypeError('Invalid virtual path in --map spec');
47
+ }
48
+ options.mounts[virtual] = dir;
49
+ };
30
50
  for (let i = 0; i < argv.length; i++) {
31
51
  const arg = argv[i];
32
52
  if (arg === '--help'
@@ -66,6 +86,16 @@ function parseArgs(argv) {
66
86
  options.host = value;
67
87
  continue;
68
88
  }
89
+ if (arg === '--map'
90
+ || arg === '--mount'
91
+ || arg === '-m') {
92
+ const value = argv[++i];
93
+ if (!value) {
94
+ throw new TypeError('Missing value for --map');
95
+ }
96
+ addMount(value);
97
+ continue;
98
+ }
69
99
  if (arg.startsWith('-')) {
70
100
  throw new TypeError(`Unknown option: ${arg}`);
71
101
  }
package/dist/send.d.ts CHANGED
@@ -1,13 +1,65 @@
1
+ /**
2
+ * Functions for sending HTTP responses.
3
+ *
4
+ * Exported helpers:
5
+ *
6
+ * - `options(ServerResponse, OutgoingHttpHeaders): void`
7
+ * - `api(ServerResponse, unknown): void`
8
+ * - `apiError(ServerResponse, unknown): void`
9
+ * - `json(ServerResponse, number, unknown): void`
10
+ * - `html(ServerResponse, string): void`
11
+ * - `badRequest(ServerResponse, string?): void`
12
+ * - `forbidden(ServerResponse, string?): void`
13
+ * - `notFound(ServerResponse, string?): void`
14
+ * - `methodNotAllowed(ServerResponse, string?): void`
15
+ * - `error(ServerResponse, unknown): void`
16
+ * - `file(ServerResponse, string): Promise<void>`
17
+ */
1
18
  import type { OutgoingHttpHeaders } from 'node:http';
2
19
  import type { ServerResponse } from 'node:http';
20
+ /**
21
+ * Sends an empty response to the client for an OPTIONS request.
22
+ * Typically used for CORS preflight requests.
23
+ */
3
24
  export declare function options(response: ServerResponse, headers: OutgoingHttpHeaders): void;
25
+ /**
26
+ * Sends a JSON API response with status 200.
27
+ */
4
28
  export declare function api(response: ServerResponse, payload: unknown): void;
29
+ /**
30
+ * Sends an API error message to the response with status 500.
31
+ */
5
32
  export declare function apiError(response: ServerResponse, error: unknown): void;
33
+ /**
34
+ * Sends a JSON string to the response with the specified status.
35
+ */
6
36
  export declare function json(response: ServerResponse, status: number, jsonString: unknown): void;
37
+ /**
38
+ * Sends an HTML string to the response with status 200.
39
+ */
7
40
  export declare function html(response: ServerResponse, htmlString: string): void;
41
+ /**
42
+ * Sends a "Bad request" error message to the response with status 400.
43
+ */
8
44
  export declare function badRequest(response: ServerResponse, message?: string): void;
45
+ /**
46
+ * Sends a "Forbidden" error message to the response with status 403.
47
+ */
9
48
  export declare function forbidden(response: ServerResponse, message?: string): void;
49
+ /**
50
+ * Sends a "Not found" error message to the response with status 404.
51
+ */
10
52
  export declare function notFound(response: ServerResponse, message?: string): void;
53
+ /**
54
+ * Sends a "Method not allowed" error message to the response with status 405.
55
+ */
11
56
  export declare function methodNotAllowed(response: ServerResponse, allowed?: string): void;
57
+ /**
58
+ * Sends an error message to the response with status 500.
59
+ */
12
60
  export declare function error(response: ServerResponse, err: unknown): void;
61
+ /**
62
+ * Sends a static file, specified by `filePath`, to the response,
63
+ * asynchronously.
64
+ */
13
65
  export declare function file(response: ServerResponse, filePath: string): Promise<void>;
package/dist/send.js CHANGED
@@ -1,6 +1,25 @@
1
+ /**
2
+ * Functions for sending HTTP responses.
3
+ *
4
+ * Exported helpers:
5
+ *
6
+ * - `options(ServerResponse, OutgoingHttpHeaders): void`
7
+ * - `api(ServerResponse, unknown): void`
8
+ * - `apiError(ServerResponse, unknown): void`
9
+ * - `json(ServerResponse, number, unknown): void`
10
+ * - `html(ServerResponse, string): void`
11
+ * - `badRequest(ServerResponse, string?): void`
12
+ * - `forbidden(ServerResponse, string?): void`
13
+ * - `notFound(ServerResponse, string?): void`
14
+ * - `methodNotAllowed(ServerResponse, string?): void`
15
+ * - `error(ServerResponse, unknown): void`
16
+ * - `file(ServerResponse, string): Promise<void>`
17
+ */
1
18
  import fs from 'node:fs';
2
19
  import fsp from 'node:fs/promises';
3
20
  import path from 'node:path';
21
+ // Map of file extensions to content types.
22
+ // Used by `file()` to set the Content-Type header.
4
23
  const CONTENT_TYPES = new Map([['.html', 'text/html; charset=utf-8'],
5
24
  ['.htm', 'text/html; charset=utf-8'],
6
25
  ['.js', 'text/javascript; charset=utf-8'],
@@ -23,14 +42,24 @@ function contentTypeFor(filePath) {
23
42
  return CONTENT_TYPES.get(ext)
24
43
  || 'application/octet-stream';
25
44
  }
45
+ /**
46
+ * Sends an empty response to the client for an OPTIONS request.
47
+ * Typically used for CORS preflight requests.
48
+ */
26
49
  export function options(response, headers) {
27
50
  response.writeHead(204, headers);
28
51
  response.end();
29
52
  }
53
+ /**
54
+ * Sends a JSON API response with status 200.
55
+ */
30
56
  export function api(response, payload) {
31
57
  response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
32
58
  response.end(JSON.stringify(payload));
33
59
  }
60
+ /**
61
+ * Sends an API error message to the response with status 500.
62
+ */
34
63
  export function apiError(response, error) {
35
64
  response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
36
65
  const message = (error && typeof error === 'object' && 'message' in error)
@@ -38,33 +67,54 @@ export function apiError(response, error) {
38
67
  : String(error);
39
68
  response.end(JSON.stringify({ error: message }));
40
69
  }
70
+ /**
71
+ * Sends a JSON string to the response with the specified status.
72
+ */
41
73
  export function json(response, status, jsonString) {
42
74
  response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
43
75
  response.end(typeof jsonString === 'string'
44
76
  ? jsonString
45
77
  : JSON.stringify(jsonString));
46
78
  }
79
+ /**
80
+ * Sends an HTML string to the response with status 200.
81
+ */
47
82
  export function html(response, htmlString) {
48
83
  response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
49
84
  response.end(htmlString);
50
85
  }
86
+ /**
87
+ * Sends a "Bad request" error message to the response with status 400.
88
+ */
51
89
  export function badRequest(response, message = 'Bad request') {
52
90
  response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
53
91
  response.end(message);
54
92
  }
93
+ /**
94
+ * Sends a "Forbidden" error message to the response with status 403.
95
+ */
55
96
  export function forbidden(response, message = 'Forbidden') {
56
97
  response.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
57
98
  response.end(message);
58
99
  }
100
+ /**
101
+ * Sends a "Not found" error message to the response with status 404.
102
+ */
59
103
  export function notFound(response, message = 'Not found') {
60
104
  response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
61
105
  response.end(message);
62
106
  }
107
+ /**
108
+ * Sends a "Method not allowed" error message to the response with status 405.
109
+ */
63
110
  export function methodNotAllowed(response, allowed = 'GET') {
64
111
  response.writeHead(405, { 'content-type': 'text/plain; charset=utf-8',
65
112
  'allow': allowed });
66
113
  response.end('Method not allowed');
67
114
  }
115
+ /**
116
+ * Sends an error message to the response with status 500.
117
+ */
68
118
  export function error(response, err) {
69
119
  response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
70
120
  const message = (err && typeof err === 'object' && 'stack' in err)
@@ -74,6 +124,10 @@ export function error(response, err) {
74
124
  : String(err);
75
125
  response.end(message);
76
126
  }
127
+ /**
128
+ * Sends a static file, specified by `filePath`, to the response,
129
+ * asynchronously.
130
+ */
77
131
  export async function file(response, filePath) {
78
132
  try {
79
133
  const stat = await fsp.stat(filePath);
package/dist/server.d.ts CHANGED
@@ -8,5 +8,6 @@ export type StartServerOptions = {
8
8
  port?: number;
9
9
  host?: string;
10
10
  logger?: ServerLogger;
11
+ mounts?: Record<string, string>;
11
12
  };
12
- export declare function startServer({ root, port, host, logger }?: StartServerOptions): Server;
13
+ export declare function startServer({ root, port, host, logger, mounts }?: StartServerOptions): Server;
package/dist/server.js CHANGED
@@ -4,52 +4,10 @@ import path from 'node:path';
4
4
  import { URL, pathToFileURL } from 'node:url';
5
5
  import * as send from './send.js';
6
6
  import { watchStaticTree } from './watch.js';
7
- 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
- };
7
+ import { VirtualFolders } from './virtual-folders.js';
8
+ /**
9
+ * Reads the body of the given request up to the given size limit.
10
+ */
53
11
  function readBody(request, limit) {
54
12
  return new Promise((resolve, reject) => {
55
13
  let size = 0;
@@ -71,11 +29,21 @@ function readBody(request, limit) {
71
29
  request.on('error', reject);
72
30
  });
73
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
+ `;
74
41
  const options = { 'access-control-allow-origin': '*',
75
42
  'access-control-allow-methods': 'GET,POST,PUT,OPTIONS',
76
43
  'access-control-allow-headers': 'content-type' };
77
- export function startServer({ root = '.', port = 3000, host = 'localhost', logger = console } = {}) {
44
+ export function startServer({ root = '.', port = 3000, host = 'localhost', logger = console, mounts = {} } = {}) {
78
45
  const FILES_DIR = path.resolve(root);
46
+ const virtualFolders = new VirtualFolders(FILES_DIR, mounts);
79
47
  // ---------- hot reload (SSE)
80
48
  const sseClients = new Set();
81
49
  const sseHeaders = { 'content-type': 'text/event-stream',
@@ -105,17 +73,22 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
105
73
  }
106
74
  }, 50);
107
75
  };
108
- watchStaticTree(FILES_DIR, () => broadcastReload());
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
+ }
109
85
  // ---------- File API helpers
110
86
  const fileApiPath = (relativePath) => {
111
- const full = path.join(FILES_DIR, relativePath);
112
- return isPathInside(full, FILES_DIR)
113
- ? full
114
- : null;
87
+ return virtualFolders.toPhysicalPath(relativePath);
115
88
  };
116
89
  async function handleFileApi(request, response, url) {
117
90
  await fsp.mkdir(FILES_DIR, { recursive: true });
118
- const relativePath = safeRelativeApiPath(url.searchParams.get('path'));
91
+ const relativePath = VirtualFolders.safeRelativePath(url.searchParams.get('path'));
119
92
  if (!relativePath) {
120
93
  return send.badRequest(response, 'Invalid "path". Use a relative path with URL-style separators.');
121
94
  }
@@ -159,7 +132,7 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
159
132
  return send.methodNotAllowed(response, 'GET');
160
133
  }
161
134
  await fsp.mkdir(FILES_DIR, { recursive: true });
162
- const relativePath = safeRelativeApiPath(url.searchParams.get('path'), { allowEmpty: true });
135
+ const relativePath = VirtualFolders.safeRelativePath(url.searchParams.get('path'), { allowEmpty: true });
163
136
  if (relativePath === null) {
164
137
  return send.badRequest(response, 'Invalid "path". Use a relative directory path.');
165
138
  }
@@ -209,9 +182,18 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
209
182
  if (pathname === '/api/files') {
210
183
  return handleFilesApi(request, response, url);
211
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
+ }
212
192
  // Resolve file path
213
- let filePath = path.normalize(path.join(FILES_DIR, pathname));
214
- if (!isPathInside(filePath, FILES_DIR)) {
193
+ let filePath = safeUrlPath === ''
194
+ ? FILES_DIR
195
+ : virtualFolders.toPhysicalPath(safeUrlPath);
196
+ if (!filePath) {
215
197
  return send.forbidden(response);
216
198
  }
217
199
  // If path is dir, try index.html
@@ -235,8 +217,8 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
235
217
  const html = await fsp.readFile(filePath, 'utf8');
236
218
  const htmlWithReload = html.includes('/__events')
237
219
  ? html
238
- : html.replace(/<\/body\s*>/i, m => `${RELOAD_SNIPPET}\n${m}`) +
239
- (!html.match(/<\/body\s*>/i)
220
+ : html.replace(/<\/body\s*>/i, m => `${RELOAD_SNIPPET}\n${m}`)
221
+ + (!html.match(/<\/body\s*>/i)
240
222
  ? `\n${RELOAD_SNIPPET}`
241
223
  : '');
242
224
  return send.html(response, htmlWithReload);
@@ -258,8 +240,19 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
258
240
  send.error(response, error);
259
241
  });
260
242
  });
243
+ server.on('close', () => {
244
+ for (const stop of stopWatchers) {
245
+ try {
246
+ stop();
247
+ }
248
+ catch { }
249
+ }
250
+ });
261
251
  server.listen(port, host, () => {
262
252
  logger.log(`FILES: ${FILES_DIR}`);
253
+ for (const mount of virtualFolders.mounts) {
254
+ logger.log(`MAP: /${mount.virtual} -> ${mount.rootDir}`);
255
+ }
263
256
  logger.log(`URL: http://${host}:${port}`);
264
257
  });
265
258
  return server;
@@ -0,0 +1,19 @@
1
+ export type VerifySafeRelativePathOptions = {
2
+ allowEmpty?: boolean;
3
+ };
4
+ export type VirtualFolderMount = {
5
+ virtual: string;
6
+ rootDir: string;
7
+ };
8
+ export declare class VirtualFolders {
9
+ readonly baseDir: string;
10
+ readonly mounts: ReadonlyArray<VirtualFolderMount>;
11
+ constructor(baseDir: string, mounts?: Record<string, string>);
12
+ static safeRelativePath(value: string | null | undefined, { allowEmpty }?: VerifySafeRelativePathOptions): string | null;
13
+ getWatchRoots(): ReadonlyArray<string>;
14
+ resolve(relativePath: string): {
15
+ rootDir: string;
16
+ relativePath: string;
17
+ };
18
+ toPhysicalPath(relativePath: string): string | null;
19
+ }
@@ -0,0 +1,102 @@
1
+ import path from 'node:path';
2
+ function isPathInside(child, parent) {
3
+ const childPath = path.resolve(path.normalize(child));
4
+ const parentPath = path.resolve(path.normalize(parent));
5
+ return childPath === parentPath
6
+ || childPath.startsWith(parentPath + path.sep);
7
+ }
8
+ export class VirtualFolders {
9
+ constructor(baseDir, mounts = {}) {
10
+ if (!baseDir
11
+ || typeof baseDir !== 'string') {
12
+ throw new TypeError('VirtualFolders: baseDir must be a string');
13
+ }
14
+ this.baseDir =
15
+ path.resolve(path.normalize(baseDir));
16
+ this.mounts =
17
+ Object.entries(mounts)
18
+ .map(([virtual, dir]) => {
19
+ const safeVirtual = VirtualFolders.safeRelativePath(virtual);
20
+ if (!safeVirtual) {
21
+ throw new TypeError('Invalid mount virtual path');
22
+ }
23
+ if (typeof dir !== 'string') {
24
+ throw new TypeError('Invalid mount dir');
25
+ }
26
+ return {
27
+ virtual: safeVirtual,
28
+ rootDir: path.resolve(path.normalize(dir))
29
+ };
30
+ })
31
+ // Prefer longer (more specific) virtual paths.
32
+ .sort((a, b) => b.virtual.length
33
+ - a.virtual.length);
34
+ }
35
+ static safeRelativePath(value, { allowEmpty = false } = {}) {
36
+ if (value === null
37
+ || value === undefined) {
38
+ return allowEmpty
39
+ ? ''
40
+ : null;
41
+ }
42
+ if (typeof value !== 'string')
43
+ return null;
44
+ if (value === '') {
45
+ return allowEmpty
46
+ ? ''
47
+ : null;
48
+ }
49
+ if (!/^[a-zA-Z0-9._/-]+$/.test(value))
50
+ return null;
51
+ // Force URL-style separators.
52
+ if (value.includes('\\'))
53
+ return null;
54
+ if (value.startsWith('/'))
55
+ return null;
56
+ const segments = value.split('/');
57
+ for (const segment of segments) {
58
+ if (!segment
59
+ || segment === '.'
60
+ || segment === '..') {
61
+ return null;
62
+ }
63
+ }
64
+ return value;
65
+ }
66
+ getWatchRoots() {
67
+ const set = new Set();
68
+ set.add(this.baseDir);
69
+ for (const mount of this.mounts) {
70
+ set.add(mount.rootDir);
71
+ }
72
+ return Array.from(set);
73
+ }
74
+ resolve(relativePath) {
75
+ for (const mount of this.mounts) {
76
+ if (relativePath === mount.virtual) {
77
+ return {
78
+ rootDir: mount.rootDir,
79
+ relativePath: ''
80
+ };
81
+ }
82
+ const prefix = mount.virtual + '/';
83
+ if (relativePath.startsWith(prefix)) {
84
+ return {
85
+ rootDir: mount.rootDir,
86
+ relativePath: relativePath.slice(prefix.length)
87
+ };
88
+ }
89
+ }
90
+ return {
91
+ rootDir: this.baseDir,
92
+ relativePath
93
+ };
94
+ }
95
+ toPhysicalPath(relativePath) {
96
+ const resolved = this.resolve(relativePath);
97
+ const full = path.join(resolved.rootDir, resolved.relativePath);
98
+ return isPathInside(full, resolved.rootDir)
99
+ ? full
100
+ : null;
101
+ }
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asljs-server",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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",