asljs-server 0.2.0

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2025 Alexandrite Software Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # asljs-server
2
+
3
+ A lightweight development-time static files server with live-reload (SSE) and
4
+ a filesystem read/write API.
5
+
6
+ ## CLI
7
+
8
+ After installing:
9
+
10
+ - `asljs-server`
11
+
12
+ Or without installing:
13
+
14
+ - `npx asljs-server`
15
+
16
+ ### Examples
17
+
18
+ - Serve current folder on port 3000:
19
+ - `asljs-server`
20
+
21
+ - Serve a specific folder:
22
+ - `asljs-server ./public`
23
+ - `asljs-server --root ./public`
24
+
25
+ - Change port/host:
26
+ - `asljs-server --port 8080`
27
+ - `asljs-server --host 0.0.0.0 --port 8080`
28
+
29
+ ## JSON API
30
+
31
+ - `GET /api/json?file=name[.json]` returns the JSON file
32
+ - `PUT|POST /api/json?file=name[.json]` writes JSON (pretty-printed)
33
+
34
+ ## Live reload
35
+
36
+ HTML pages get a small client injected that listens on:
37
+
38
+ - `GET /__events`
package/cli.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import './dist/cli.js';
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,102 @@
1
+ import { startServer } from './server.js';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
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
+ }
19
+ function readVersion() {
20
+ const here = path.dirname(fileURLToPath(import.meta.url));
21
+ const packageJsonPath = path.join(here, '..', 'package.json');
22
+ const raw = readFileSync(packageJsonPath, 'utf8');
23
+ return JSON.parse(raw)
24
+ .version;
25
+ }
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;
60
+ }
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;
74
+ }
75
+ return { kind: 'run', options };
76
+ }
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;
91
+ }
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();
101
+ process.exitCode = 1;
102
+ }
package/dist/send.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { OutgoingHttpHeaders } from 'node:http';
2
+ import type { ServerResponse } from 'node:http';
3
+ export declare function options(response: ServerResponse, headers: OutgoingHttpHeaders): void;
4
+ export declare function api(response: ServerResponse, payload: unknown): void;
5
+ export declare function apiError(response: ServerResponse, error: unknown): void;
6
+ export declare function json(response: ServerResponse, status: number, jsonString: unknown): void;
7
+ export declare function html(response: ServerResponse, htmlString: string): void;
8
+ export declare function badRequest(response: ServerResponse, message?: string): void;
9
+ export declare function forbidden(response: ServerResponse, message?: string): void;
10
+ export declare function notFound(response: ServerResponse, message?: string): void;
11
+ export declare function methodNotAllowed(response: ServerResponse, allowed?: string): void;
12
+ export declare function error(response: ServerResponse, err: unknown): void;
13
+ export declare function file(response: ServerResponse, filePath: string): Promise<void>;
package/dist/send.js ADDED
@@ -0,0 +1,95 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ const CONTENT_TYPES = new Map([['.html', 'text/html; charset=utf-8'],
5
+ ['.htm', 'text/html; charset=utf-8'],
6
+ ['.js', 'text/javascript; charset=utf-8'],
7
+ ['.mjs', 'text/javascript; charset=utf-8'],
8
+ ['.css', 'text/css; charset=utf-8'],
9
+ ['.json', 'application/json; charset=utf-8'],
10
+ ['.txt', 'text/plain; charset=utf-8'],
11
+ ['.svg', 'image/svg+xml'],
12
+ ['.png', 'image/png'],
13
+ ['.jpg', 'image/jpeg'],
14
+ ['.jpeg', 'image/jpeg'],
15
+ ['.gif', 'image/gif'],
16
+ ['.webp', 'image/webp'],
17
+ ['.ico', 'image/x-icon'],
18
+ ['.woff', 'font/woff'],
19
+ ['.woff2', 'font/woff2']]);
20
+ function contentTypeFor(filePath) {
21
+ const ext = path.extname(filePath)
22
+ .toLowerCase();
23
+ return CONTENT_TYPES.get(ext)
24
+ || 'application/octet-stream';
25
+ }
26
+ export function options(response, headers) {
27
+ response.writeHead(204, headers);
28
+ response.end();
29
+ }
30
+ export function api(response, payload) {
31
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
32
+ response.end(JSON.stringify(payload));
33
+ }
34
+ export function apiError(response, error) {
35
+ response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
36
+ const message = (error && typeof error === 'object' && 'message' in error)
37
+ ? String(error.message)
38
+ : String(error);
39
+ response.end(JSON.stringify({ error: message }));
40
+ }
41
+ export function json(response, status, jsonString) {
42
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
43
+ response.end(typeof jsonString === 'string'
44
+ ? jsonString
45
+ : JSON.stringify(jsonString));
46
+ }
47
+ export function html(response, htmlString) {
48
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
49
+ response.end(htmlString);
50
+ }
51
+ export function badRequest(response, message = 'Bad request') {
52
+ response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
53
+ response.end(message);
54
+ }
55
+ export function forbidden(response, message = 'Forbidden') {
56
+ response.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
57
+ response.end(message);
58
+ }
59
+ export function notFound(response, message = 'Not found') {
60
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
61
+ response.end(message);
62
+ }
63
+ export function methodNotAllowed(response, allowed = 'GET') {
64
+ response.writeHead(405, { 'content-type': 'text/plain; charset=utf-8',
65
+ 'allow': allowed });
66
+ response.end('Method not allowed');
67
+ }
68
+ export function error(response, err) {
69
+ response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
70
+ const message = (err && typeof err === 'object' && 'stack' in err)
71
+ ? String(err.stack)
72
+ : (err && typeof err === 'object' && 'message' in err)
73
+ ? String(err.message)
74
+ : String(err);
75
+ response.end(message);
76
+ }
77
+ export async function file(response, filePath) {
78
+ try {
79
+ const stat = await fsp.stat(filePath);
80
+ response.writeHead(200, { 'content-type': contentTypeFor(filePath),
81
+ 'content-length': stat.size });
82
+ fs.createReadStream(filePath)
83
+ .on('error', err => error(response, err))
84
+ .pipe(response);
85
+ }
86
+ catch (err) {
87
+ if (err
88
+ && typeof err === 'object'
89
+ && 'code' in err
90
+ && err.code === 'ENOENT') {
91
+ return notFound(response);
92
+ }
93
+ return error(response, err);
94
+ }
95
+ }
@@ -0,0 +1,12 @@
1
+ import type { Server } from 'node:http';
2
+ export type ServerLogger = {
3
+ log: (...args: unknown[]) => void;
4
+ error: (...args: unknown[]) => void;
5
+ };
6
+ export type StartServerOptions = {
7
+ root?: string;
8
+ port?: number;
9
+ host?: string;
10
+ logger?: ServerLogger;
11
+ };
12
+ export declare function startServer({ root, port, host, logger }?: StartServerOptions): Server;
package/dist/server.js ADDED
@@ -0,0 +1,199 @@
1
+ import http from 'node:http';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { URL, pathToFileURL } from 'node:url';
5
+ 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
+ function readBody(request, limit) {
24
+ return new Promise((resolve, reject) => {
25
+ let size = 0;
26
+ const chunks = [];
27
+ request.on('data', chunk => {
28
+ const buffer = Buffer.isBuffer(chunk)
29
+ ? chunk
30
+ : Buffer.from(chunk);
31
+ size += buffer.length;
32
+ if (size > limit) {
33
+ reject(new Error('Payload too large'));
34
+ request.destroy();
35
+ return;
36
+ }
37
+ chunks.push(buffer);
38
+ });
39
+ request.on('end', () => resolve(Buffer.concat(chunks)
40
+ .toString('utf8')));
41
+ request.on('error', reject);
42
+ });
43
+ }
44
+ const options = { 'access-control-allow-origin': '*',
45
+ 'access-control-allow-methods': 'GET,POST,PUT,OPTIONS',
46
+ 'access-control-allow-headers': 'content-type' };
47
+ export function startServer({ root = '.', port = 3000, host = 'localhost', logger = console } = {}) {
48
+ const FILES_DIR = path.resolve(root);
49
+ // ---------- hot reload (SSE)
50
+ const sseClients = new Set();
51
+ const sseHeaders = { 'content-type': 'text/event-stream',
52
+ 'cache-control': 'no-cache',
53
+ 'connection': 'keep-alive',
54
+ 'x-accel-buffering': 'no' };
55
+ const serveSSE = (req, res) => {
56
+ res.writeHead(200, sseHeaders);
57
+ res.write(': connected\n\n'); // comment = keep-alive
58
+ sseClients.add(res);
59
+ const ping = setInterval(() => res.write(': ping\n\n'), 30000);
60
+ req.on('close', () => {
61
+ clearInterval(ping);
62
+ sseClients.delete(res);
63
+ });
64
+ };
65
+ let reloadPending = false;
66
+ const broadcastReload = () => {
67
+ if (reloadPending)
68
+ return;
69
+ reloadPending = true;
70
+ setTimeout(() => {
71
+ reloadPending = false;
72
+ for (const res of sseClients) {
73
+ res.write('event: reload\n');
74
+ res.write('data: now\n\n');
75
+ }
76
+ }, 50);
77
+ };
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;
91
+ };
92
+ async function handleJsonApi(request, response, url) {
93
+ 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');
98
+ }
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
+ }
108
+ }
109
+ if (request.method === 'PUT'
110
+ || request.method === 'POST') {
111
+ try {
112
+ // 1MB limit
113
+ const body = await readBody(request, 1000000);
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(body);
117
+ }
118
+ catch {
119
+ return send.badRequest(response, 'Invalid JSON');
120
+ }
121
+ await fsp.writeFile(file, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
122
+ return send.api(response, { file: path.basename(file) });
123
+ }
124
+ catch (error) {
125
+ return send.apiError(response, error);
126
+ }
127
+ }
128
+ return send.methodNotAllowed(response, 'GET, PUT, POST');
129
+ }
130
+ async function serveStatic(request, response, url) {
131
+ const pathname = decodeURIComponent(url.pathname);
132
+ // SSE endpoint
133
+ if (pathname === '/__events') {
134
+ return serveSSE(request, response);
135
+ }
136
+ // JSON API: /api/json?file=name[.json]
137
+ if (pathname === '/api/json') {
138
+ return handleJsonApi(request, response, url);
139
+ }
140
+ // Resolve file path
141
+ let filePath = path.normalize(path.join(FILES_DIR, pathname));
142
+ if (!isPathInside(filePath, FILES_DIR)) {
143
+ return send.forbidden(response);
144
+ }
145
+ // If path is dir, try index.html
146
+ try {
147
+ const stat = await fsp.stat(filePath);
148
+ if (stat.isDirectory()) {
149
+ filePath =
150
+ path.join(filePath, 'index.html');
151
+ await fsp.stat(filePath);
152
+ }
153
+ }
154
+ catch {
155
+ return send.notFound(response);
156
+ }
157
+ const ext = path.extname(filePath)
158
+ .toLowerCase();
159
+ // HTML: inject hot-reload snippet
160
+ if (ext === '.html'
161
+ && request.method === 'GET') {
162
+ try {
163
+ const html = await fsp.readFile(filePath, 'utf8');
164
+ const htmlWithReload = html.includes('/__events')
165
+ ? html
166
+ : html.replace(/<\/body\s*>/i, m => `${RELOAD_SNIPPET}\n${m}`) +
167
+ (!html.match(/<\/body\s*>/i)
168
+ ? `\n${RELOAD_SNIPPET}`
169
+ : '');
170
+ return send.html(response, htmlWithReload);
171
+ }
172
+ catch (error) {
173
+ return send.error(response, error);
174
+ }
175
+ }
176
+ return send.file(response, filePath);
177
+ }
178
+ const server = http.createServer((request, response) => {
179
+ if (request.method === 'OPTIONS') {
180
+ return send.options(response, options);
181
+ }
182
+ logger.log(`${request.method} ${request.url}`);
183
+ serveStatic(request, response, new URL(request.url || '/', `http://${request.headers.host || `${host}:${port}`}`))
184
+ .catch(error => {
185
+ logger.error(error);
186
+ send.error(response, error);
187
+ });
188
+ });
189
+ server.listen(port, host, () => {
190
+ logger.log(`FILES: ${FILES_DIR}`);
191
+ logger.log(`URL: http://${host}:${port}`);
192
+ });
193
+ return server;
194
+ }
195
+ const entryFile = process.argv[1];
196
+ if (entryFile
197
+ && import.meta.url === pathToFileURL(entryFile).href) {
198
+ startServer();
199
+ }
@@ -0,0 +1 @@
1
+ export declare function watchStaticTree(rootDir: string, onChange: () => void): () => void;
package/dist/watch.js ADDED
@@ -0,0 +1,21 @@
1
+ import fs from 'node:fs';
2
+ export function watchStaticTree(rootDir, onChange) {
3
+ // Best-effort cross-platform watcher.
4
+ // On Windows/macOS, recursive fs.watch works.
5
+ // On Linux, recursive is not supported, but this package is intended for dev-time usage.
6
+ let watcher;
7
+ try {
8
+ watcher =
9
+ fs.watch(rootDir, { recursive: true }, () => onChange());
10
+ }
11
+ catch {
12
+ watcher =
13
+ fs.watch(rootDir, () => onChange());
14
+ }
15
+ return () => {
16
+ try {
17
+ watcher.close();
18
+ }
19
+ catch { }
20
+ };
21
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "asljs-server",
3
+ "version": "0.2.0",
4
+ "description": "A lightweight development-time static files server. Supports live-reload. Provides filesystem read-write API.",
5
+ "type": "module",
6
+ "main": "server.js",
7
+ "types": "server.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./server.d.ts",
11
+ "default": "./server.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "asljs-server": "./cli.js"
16
+ },
17
+ "files": [
18
+ "dist/**",
19
+ "server.js",
20
+ "server.d.ts",
21
+ "cli.js",
22
+ "README.md",
23
+ "LICENSE.md"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc -p .",
27
+ "lint": "eslint .",
28
+ "lint:fix": "eslint . --fix",
29
+ "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)')\""
33
+ },
34
+ "keywords": [
35
+ "server",
36
+ "development",
37
+ "live-reload"
38
+ ],
39
+ "author": "Alex Netkachov <alex.netkachov@gmail.com>",
40
+ "license": "MIT",
41
+ "homepage": "https://github.com/AlexandriteSoftware/asljs#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/AlexandriteSoftware/asljs/issues"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/AlexandriteSoftware/asljs.git"
48
+ }
49
+ }
package/server.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export {
2
+ startServer
3
+ } from './dist/server.js';
4
+
5
+ export type {
6
+ ServerLogger,
7
+ StartServerOptions
8
+ } from './dist/server.js';
package/server.js ADDED
@@ -0,0 +1,3 @@
1
+ export {
2
+ startServer
3
+ } from './dist/server.js';