asljs-server 0.2.0 → 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,10 +26,19 @@ Or without installing:
26
26
  - `asljs-server --port 8080`
27
27
  - `asljs-server --host 0.0.0.0 --port 8080`
28
28
 
29
- ## JSON API
29
+ - Map a folder under a virtual path:
30
+ - `asljs-server --map assets=../Assets`
31
+ - Serves `../Assets/logo.png` as `/assets/logo.png`
30
32
 
31
- - `GET /api/json?file=name[.json]` returns the JSON file
32
- - `PUT|POST /api/json?file=name[.json]` writes JSON (pretty-printed)
33
+ ## File API
34
+
35
+ - `GET /api/file?path=path` returns the file contents
36
+ - `PUT|POST /api/file?path=path` writes the file
37
+ - `GET /api/files?path=path` lists all files in the directory
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`
33
42
 
34
43
  ## Live reload
35
44
 
package/cli.js CHANGED
@@ -1,3 +1,3 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  import './dist/cli.js';
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,22 +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;
7
+ import { VirtualFolders } from './virtual-folders.js';
8
+ /**
9
+ * Reads the body of the given request up to the given size limit.
10
+ */
23
11
  function readBody(request, limit) {
24
12
  return new Promise((resolve, reject) => {
25
13
  let size = 0;
@@ -41,11 +29,21 @@ function readBody(request, limit) {
41
29
  request.on('error', reject);
42
30
  });
43
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
+ `;
44
41
  const options = { 'access-control-allow-origin': '*',
45
42
  'access-control-allow-methods': 'GET,POST,PUT,OPTIONS',
46
43
  'access-control-allow-headers': 'content-type' };
47
- export function startServer({ root = '.', port = 3000, host = 'localhost', logger = console } = {}) {
44
+ export function startServer({ root = '.', port = 3000, host = 'localhost', logger = console, mounts = {} } = {}) {
48
45
  const FILES_DIR = path.resolve(root);
46
+ const virtualFolders = new VirtualFolders(FILES_DIR, mounts);
49
47
  // ---------- hot reload (SSE)
50
48
  const sseClients = new Set();
51
49
  const sseHeaders = { 'content-type': 'text/event-stream',
@@ -75,51 +73,53 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
75
73
  }
76
74
  }, 50);
77
75
  };
78
- watchStaticTree(FILES_DIR, () => broadcastReload());
79
- // ---------- JSON API helpers
80
- const jsonFilePath = (name) => {
81
- const base = safeJsonName(name);
82
- if (!base)
83
- return null;
84
- const file = base.endsWith('.json')
85
- ? base
86
- : (base + '.json');
87
- const full = path.join(FILES_DIR, file);
88
- return isPathInside(full, FILES_DIR)
89
- ? full
90
- : null;
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);
91
88
  };
92
- async function handleJsonApi(request, response, url) {
89
+ async function handleFileApi(request, response, url) {
93
90
  await fsp.mkdir(FILES_DIR, { recursive: true });
94
- const name = url.searchParams.get('file');
95
- const file = jsonFilePath(name);
96
- if (!file) {
97
- return send.badRequest(response, 'Invalid "file" name. Allowed: [a-zA-Z0-9._-] and optional .json');
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
98
  }
99
99
  if (request.method === 'GET') {
100
- try {
101
- return send.json(response, 200, await fsp.readFile(file, 'utf8'));
102
- }
103
- catch (e) {
104
- if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT')
105
- return send.notFound(response, 'JSON file not found');
106
- throw e;
107
- }
100
+ return send.file(response, filePath);
108
101
  }
109
102
  if (request.method === 'PUT'
110
103
  || request.method === 'POST') {
111
104
  try {
112
105
  // 1MB limit
113
106
  const body = await readBody(request, 1000000);
114
- let parsed;
115
- try {
116
- parsed = JSON.parse(body);
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');
117
118
  }
118
- catch {
119
- return send.badRequest(response, 'Invalid JSON');
119
+ else {
120
+ await fsp.writeFile(filePath, body, 'utf8');
120
121
  }
121
- await fsp.writeFile(file, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
122
- return send.api(response, { file: path.basename(file) });
122
+ return send.api(response, { path: relativePath });
123
123
  }
124
124
  catch (error) {
125
125
  return send.apiError(response, error);
@@ -127,19 +127,73 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
127
127
  }
128
128
  return send.methodNotAllowed(response, 'GET, PUT, POST');
129
129
  }
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');
147
+ }
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
+ }
170
+ }
130
171
  async function serveStatic(request, response, url) {
131
172
  const pathname = decodeURIComponent(url.pathname);
132
173
  // SSE endpoint
133
174
  if (pathname === '/__events') {
134
175
  return serveSSE(request, response);
135
176
  }
136
- // JSON API: /api/json?file=name[.json]
137
- if (pathname === '/api/json') {
138
- return handleJsonApi(request, response, url);
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);
139
191
  }
140
192
  // Resolve file path
141
- let filePath = path.normalize(path.join(FILES_DIR, pathname));
142
- if (!isPathInside(filePath, FILES_DIR)) {
193
+ let filePath = safeUrlPath === ''
194
+ ? FILES_DIR
195
+ : virtualFolders.toPhysicalPath(safeUrlPath);
196
+ if (!filePath) {
143
197
  return send.forbidden(response);
144
198
  }
145
199
  // If path is dir, try index.html
@@ -163,8 +217,8 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
163
217
  const html = await fsp.readFile(filePath, 'utf8');
164
218
  const htmlWithReload = html.includes('/__events')
165
219
  ? html
166
- : html.replace(/<\/body\s*>/i, m => `${RELOAD_SNIPPET}\n${m}`) +
167
- (!html.match(/<\/body\s*>/i)
220
+ : html.replace(/<\/body\s*>/i, m => `${RELOAD_SNIPPET}\n${m}`)
221
+ + (!html.match(/<\/body\s*>/i)
168
222
  ? `\n${RELOAD_SNIPPET}`
169
223
  : '');
170
224
  return send.html(response, htmlWithReload);
@@ -186,8 +240,19 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
186
240
  send.error(response, error);
187
241
  });
188
242
  });
243
+ server.on('close', () => {
244
+ for (const stop of stopWatchers) {
245
+ try {
246
+ stop();
247
+ }
248
+ catch { }
249
+ }
250
+ });
189
251
  server.listen(port, host, () => {
190
252
  logger.log(`FILES: ${FILES_DIR}`);
253
+ for (const mount of virtualFolders.mounts) {
254
+ logger.log(`MAP: /${mount.virtual} -> ${mount.rootDir}`);
255
+ }
191
256
  logger.log(`URL: http://${host}:${port}`);
192
257
  });
193
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.0",
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",