srv-it 0.1.1

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 ADDED
@@ -0,0 +1,95 @@
1
+ # srv-it
2
+
3
+ `srv-it` installs a `srv` command and merges the best parts of `live-server` and `serve`:
4
+
5
+ - one-command static server from any folder
6
+ - polished terminal UX
7
+ - live DOM reload and CSS refresh
8
+ - directory listing with customizable page style
9
+ - simple defaults via config files
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ npm install -g srv-it
15
+ ```
16
+
17
+ Then run:
18
+
19
+ ```bash
20
+ cd your-project
21
+ srv
22
+ ```
23
+
24
+ This serves the current directory on port `3000`.
25
+
26
+ ## Common usage
27
+
28
+ ```bash
29
+ srv
30
+ srv 3000
31
+ srv 8080 ./public
32
+ srv ./public --open
33
+ srv --style neon --style-css ./srv-listing.css
34
+ ```
35
+
36
+ ## Config defaults
37
+
38
+ You can set defaults globally and per-project:
39
+
40
+ - global: `~/.srvrc.json`
41
+ - project: `./srv.config.json`
42
+ - override file: `srv --config ./my-srv.json`
43
+ - template: `./srv.config.example.json`
44
+
45
+ Example `srv.config.json`:
46
+
47
+ ```json
48
+ {
49
+ "port": 3000,
50
+ "host": "0.0.0.0",
51
+ "open": false,
52
+ "single": true,
53
+ "cors": true,
54
+ "style": "midnight",
55
+ "directoryListing": true,
56
+ "noCssInject": false,
57
+ "logLevel": 2,
58
+ "noRequestLogging": false,
59
+ "ignore": ["**/.git/**", "**/node_modules/**"]
60
+ }
61
+ ```
62
+
63
+ CLI flags always override config files.
64
+
65
+ ## CLI options
66
+
67
+ Run:
68
+
69
+ ```bash
70
+ srv --help
71
+ ```
72
+
73
+ Highlights:
74
+
75
+ - `-p, --port <number>`
76
+ - `--host <host>`
77
+ - `--open [path]`, `--no-open`
78
+ - `--watch <path>` (repeat)
79
+ - `--ignore <glob>` (repeat)
80
+ - `--single`
81
+ - `--cors`
82
+ - `--no-css-inject`
83
+ - `--no-dir-listing`
84
+ - `--style <midnight|paper|neon>`
85
+ - `--style-css <file>`
86
+ - `-c` (create `srv.config.json` in served root if missing)
87
+ - `--log-level <0-3>`
88
+ - `--no-request-logging`
89
+ - `--ssl-cert <file> --ssl-key <file> [--ssl-pass <file>]`
90
+
91
+ ## Notes
92
+
93
+ - HTML pages get an auto-injected websocket client for live reload.
94
+ - CSS changes refresh styles without full page reload unless `--no-css-inject` is enabled.
95
+ - If a folder has `index.html`, that file is served; otherwise a styled directory listing is shown.
package/bin/srv.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { run } = require('../src/cli');
4
+
5
+ run().catch((error) => {
6
+ console.error(error.message || String(error));
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "srv-it",
3
+ "version": "0.1.1",
4
+ "description": "Static server with polished CLI UI, directory listing, and live reload",
5
+ "keywords": [
6
+ "server",
7
+ "static",
8
+ "live-reload",
9
+ "directory-listing",
10
+ "cli"
11
+ ],
12
+ "homepage": "https://github.com/elouan/srv-it",
13
+ "bugs": {
14
+ "url": "https://github.com/elouan/srv-it/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/elouan/srv-it.git"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Elouan Grimm",
22
+ "type": "commonjs",
23
+ "main": "./src/cli.js",
24
+ "bin": {
25
+ "srv-it": "bin/srv.js"
26
+ },
27
+ "files": [
28
+ "bin",
29
+ "src",
30
+ "README.md"
31
+ ],
32
+ "scripts": {
33
+ "start": "node ./bin/srv.js",
34
+ "test": "node ./bin/srv.js --help"
35
+ },
36
+ "dependencies": {
37
+ "arg": "^5.0.2",
38
+ "boxen": "^7.1.1",
39
+ "chalk": "^4.1.2",
40
+ "chokidar": "^3.6.0",
41
+ "compression": "^1.8.1",
42
+ "mime-types": "^2.1.35",
43
+ "open": "^10.1.2",
44
+ "ws": "^8.18.0"
45
+ },
46
+ "devDependencies": {},
47
+ "engines": {
48
+ "node": ">=18"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "preferGlobal": true
54
+ }
package/src/cli.js ADDED
@@ -0,0 +1,236 @@
1
+ const path = require('node:path');
2
+ const fs = require('node:fs');
3
+ const arg = require('arg');
4
+ const chalk = require('chalk');
5
+ const boxenModule = require('boxen');
6
+ const openModule = require('open');
7
+ const { loadConfig } = require('./config');
8
+ const { createSrvServer } = require('./server');
9
+ const pkg = require('../package.json');
10
+
11
+ const boxen = boxenModule.default || boxenModule;
12
+ const open = openModule.default || openModule;
13
+
14
+ function isNumeric(value) {
15
+ return /^\d+$/.test(String(value));
16
+ }
17
+
18
+ function getHelpText() {
19
+ return [
20
+ 'srv - static server + directory listing + live reload',
21
+ '',
22
+ 'USAGE',
23
+ ' srv Serve current directory on port 3000',
24
+ ' srv 4000 Serve current directory on port 4000',
25
+ ' srv 4000 ./public Serve ./public on port 4000',
26
+ ' srv ./public Serve ./public on port 3000',
27
+ '',
28
+ 'OPTIONS',
29
+ ' -h, --help Show help',
30
+ ' -v, --version Show version',
31
+ ' -p, --port <number> Port to listen on',
32
+ ' --host <host> Host to bind to (default 0.0.0.0)',
33
+ ' --open [path] Open browser (default /)',
34
+ ' --no-open Do not open browser',
35
+ ' --watch <path> Extra watch path (can be repeated)',
36
+ ' --ignore <glob> Ignore glob/path for watcher (repeatable)',
37
+ ' --no-css-inject Reload page on css changes instead of hot css refresh',
38
+ ' --cors Enable CORS',
39
+ ' --single SPA fallback to /index.html',
40
+ ' --no-dir-listing Disable directory listing',
41
+ ' --style <name> Listing style preset: midnight | paper | neon',
42
+ ' --style-css <file> Custom CSS file for listing page',
43
+ ' -c Create srv.config.json in served root if missing',
44
+ ' --config <file> Read additional config JSON file',
45
+ ' --no-request-logging Disable request logs',
46
+ ' --log-level <0-3> Startup log verbosity',
47
+ ' --ssl-cert <file> SSL certificate path',
48
+ ' --ssl-key <file> SSL private key path',
49
+ ' --ssl-pass <file> SSL passphrase file path',
50
+ '',
51
+ 'CONFIG FILES',
52
+ ' ~/.srvrc.json (global defaults)',
53
+ ' ./srv.config.json (project defaults)',
54
+ '',
55
+ 'CLI options always override config values.',
56
+ ].join('\n');
57
+ }
58
+
59
+ function parseCli() {
60
+ const parsed = arg(
61
+ {
62
+ '--help': Boolean,
63
+ '--version': Boolean,
64
+ '--port': Number,
65
+ '--host': String,
66
+ '--open': String,
67
+ '--no-open': Boolean,
68
+ '--watch': [String],
69
+ '--ignore': [String],
70
+ '--no-css-inject': Boolean,
71
+ '--cors': Boolean,
72
+ '--single': Boolean,
73
+ '--no-dir-listing': Boolean,
74
+ '--style': String,
75
+ '--style-css': String,
76
+ '-c': Boolean,
77
+ '--config': String,
78
+ '--no-request-logging': Boolean,
79
+ '--log-level': Number,
80
+ '--ssl-cert': String,
81
+ '--ssl-key': String,
82
+ '--ssl-pass': String,
83
+ '-h': '--help',
84
+ '-v': '--version',
85
+ '-p': '--port',
86
+ },
87
+ {
88
+ permissive: true,
89
+ },
90
+ );
91
+
92
+ return parsed;
93
+ }
94
+
95
+ function resolveOptions(parsed, mergedConfig) {
96
+ const positional = parsed._.slice();
97
+
98
+ let positionalPort;
99
+ let positionalRoot;
100
+ if (positional.length > 0) {
101
+ if (isNumeric(positional[0])) {
102
+ positionalPort = Number(positional.shift());
103
+ }
104
+ if (positional.length > 0) {
105
+ positionalRoot = positional.shift();
106
+ }
107
+ }
108
+
109
+ const options = {
110
+ port:
111
+ parsed['--port'] ?? positionalPort ?? mergedConfig.port ?? Number(process.env.PORT || 3000),
112
+ host: parsed['--host'] ?? mergedConfig.host ?? '0.0.0.0',
113
+ root: path.resolve(positionalRoot || mergedConfig.root || process.cwd()),
114
+ open:
115
+ parsed['--no-open']
116
+ ? false
117
+ : parsed['--open'] || mergedConfig.open === false
118
+ ? parsed['--open'] || mergedConfig.open
119
+ : '/',
120
+ watch: parsed['--watch'] ?? mergedConfig.watch ?? [],
121
+ ignore: parsed['--ignore'] ?? mergedConfig.ignore ?? [],
122
+ noCssInject: parsed['--no-css-inject'] ?? Boolean(mergedConfig.noCssInject),
123
+ cors: parsed['--cors'] ?? Boolean(mergedConfig.cors),
124
+ single: parsed['--single'] ?? Boolean(mergedConfig.single),
125
+ directoryListing:
126
+ parsed['--no-dir-listing']
127
+ ? false
128
+ : mergedConfig.directoryListing !== undefined
129
+ ? Boolean(mergedConfig.directoryListing)
130
+ : true,
131
+ style: parsed['--style'] ?? mergedConfig.style ?? 'midnight',
132
+ styleCss: parsed['--style-css'] ?? mergedConfig.styleCss,
133
+ noRequestLogging:
134
+ parsed['--no-request-logging'] ?? Boolean(mergedConfig.noRequestLogging),
135
+ logLevel: parsed['--log-level'] ?? mergedConfig.logLevel ?? 2,
136
+ sslCert: parsed['--ssl-cert'] ?? mergedConfig.sslCert,
137
+ sslKey: parsed['--ssl-key'] ?? mergedConfig.sslKey,
138
+ sslPass: parsed['--ssl-pass'] ?? mergedConfig.sslPass,
139
+ createConfig: Boolean(parsed['-c']),
140
+ };
141
+
142
+ return options;
143
+ }
144
+
145
+ async function run() {
146
+ const parsed = parseCli();
147
+
148
+ if (parsed['--help']) {
149
+ console.log(getHelpText());
150
+ return;
151
+ }
152
+
153
+ if (parsed['--version']) {
154
+ console.log(pkg.version);
155
+ return;
156
+ }
157
+
158
+ const { config } = loadConfig({
159
+ cwd: process.cwd(),
160
+ explicitConfigPath: parsed['--config'],
161
+ });
162
+
163
+ const options = resolveOptions(parsed, config);
164
+ let configCreated = false;
165
+
166
+ if (options.createConfig) {
167
+ const configFilePath = path.join(options.root, 'srv.config.json');
168
+ if (!fs.existsSync(configFilePath)) {
169
+ const template = {
170
+ port: 3000,
171
+ host: '0.0.0.0',
172
+ root: '.',
173
+ open: false,
174
+ watch: [],
175
+ ignore: ['**/.git/**', '**/node_modules/**'],
176
+ noCssInject: false,
177
+ cors: false,
178
+ single: false,
179
+ directoryListing: true,
180
+ style: 'midnight',
181
+ styleCss: '',
182
+ noRequestLogging: false,
183
+ logLevel: 2,
184
+ sslCert: '',
185
+ sslKey: '',
186
+ sslPass: '',
187
+ };
188
+ fs.writeFileSync(configFilePath, `${JSON.stringify(template, null, 2)}\n`, 'utf8');
189
+ configCreated = true;
190
+ }
191
+ }
192
+
193
+ const server = await createSrvServer(options);
194
+ const protocol = options.sslCert ? 'https' : 'http';
195
+ const visibleHost = options.host === '0.0.0.0' ? 'localhost' : options.host;
196
+ const url = `${protocol}://${visibleHost}:${server.port}`;
197
+
198
+ if (options.logLevel >= 1) {
199
+ const lines = [
200
+ `${chalk.green.bold('srv is running')}`,
201
+ '',
202
+ `${chalk.bold('- Local:')} ${url}`,
203
+ `${chalk.bold('- Root:')} ${options.root}`,
204
+ `${chalk.bold('- Reload:')} ${options.noCssInject ? 'full page' : 'css + page'}`,
205
+ ];
206
+
207
+ if (server.network) {
208
+ lines.splice(3, 0, `${chalk.bold('- Network:')} ${server.network}`);
209
+ }
210
+
211
+ if (configCreated) {
212
+ lines.push(`${chalk.bold('- Config:')} ${path.join(options.root, 'srv.config.json')}`);
213
+ }
214
+
215
+ const content = [
216
+ ...lines,
217
+ ].join('\n');
218
+
219
+ console.log(
220
+ boxen(content, {
221
+ padding: 1,
222
+ borderStyle: 'single',
223
+ borderColor: 'cyan',
224
+ }),
225
+ );
226
+ }
227
+
228
+ if (options.open !== false) {
229
+ const openPath = typeof options.open === 'string' ? options.open : '/';
230
+ await open(`${url}${openPath.startsWith('/') ? openPath : `/${openPath}`}`);
231
+ }
232
+ }
233
+
234
+ module.exports = {
235
+ run,
236
+ };
package/src/config.js ADDED
@@ -0,0 +1,41 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const os = require('node:os');
4
+
5
+ function readJsonIfExists(filePath) {
6
+ try {
7
+ const raw = fs.readFileSync(filePath, 'utf8');
8
+ const parsed = JSON.parse(raw);
9
+ return typeof parsed === 'object' && parsed ? parsed : {};
10
+ } catch (error) {
11
+ if (error && error.code === 'ENOENT') {
12
+ return {};
13
+ }
14
+ throw new Error(`Failed to parse config at ${filePath}: ${error.message}`);
15
+ }
16
+ }
17
+
18
+ function loadConfig({ cwd, explicitConfigPath }) {
19
+ const globalPath = path.join(os.homedir(), '.srvrc.json');
20
+ const localPath = path.join(cwd, 'srv.config.json');
21
+
22
+ const globalConfig = readJsonIfExists(globalPath);
23
+ const localConfig = readJsonIfExists(localPath);
24
+ const explicitConfig = explicitConfigPath
25
+ ? readJsonIfExists(path.resolve(explicitConfigPath))
26
+ : {};
27
+
28
+ return {
29
+ globalPath,
30
+ localPath,
31
+ config: {
32
+ ...globalConfig,
33
+ ...localConfig,
34
+ ...explicitConfig,
35
+ },
36
+ };
37
+ }
38
+
39
+ module.exports = {
40
+ loadConfig,
41
+ };
package/src/server.js ADDED
@@ -0,0 +1,483 @@
1
+ const fs = require('node:fs');
2
+ const fsp = require('node:fs/promises');
3
+ const path = require('node:path');
4
+ const http = require('node:http');
5
+ const https = require('node:https');
6
+ const { pipeline } = require('node:stream/promises');
7
+ const chokidar = require('chokidar');
8
+ const compression = require('compression');
9
+ const mime = require('mime-types');
10
+ const os = require('node:os');
11
+ const chalk = require('chalk');
12
+ const { WebSocketServer } = require('ws');
13
+
14
+ const LIVE_RELOAD_SNIPPET = `\n<script>\n(function(){\n if(!('WebSocket' in window)) return;\n function refreshCSS(){\n var links=[].slice.call(document.querySelectorAll('link[rel="stylesheet"],link:not([rel])'));\n links.forEach(function(link){\n var href=link.getAttribute('href');\n if(!href) return;\n var u=href.replace(/([?&])_srv=\\d+/,'').replace(/[?&]$/,'');\n var join=u.indexOf('?')>-1?'&':'?';\n link.setAttribute('href',u+join+'_srv='+Date.now());\n });\n }\n var proto=location.protocol==='https:'?'wss':'ws';\n var socket=new WebSocket(proto+'://'+location.host+'/__srv_ws');\n socket.onmessage=function(event){\n if(event.data==='refreshcss') refreshCSS();\n if(event.data==='reload') location.reload();\n };\n})();\n</script>\n`;
15
+
16
+ const STYLE_PRESETS = {
17
+ midnight: {
18
+ accent: '#3b82f6',
19
+ borderStrong: '#78716c',
20
+ },
21
+ paper: {
22
+ accent: '#f59e0b',
23
+ borderStrong: '#a8a29e',
24
+ },
25
+ neon: {
26
+ accent: '#22c55e',
27
+ borderStrong: '#a8a29e',
28
+ },
29
+ };
30
+
31
+ function getNetworkAddress() {
32
+ const interfaces = os.networkInterfaces();
33
+ for (const networkInterface of Object.values(interfaces)) {
34
+ if (!networkInterface) {
35
+ continue;
36
+ }
37
+ for (const details of networkInterface) {
38
+ if (details.family === 'IPv4' && !details.internal) {
39
+ return details.address;
40
+ }
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ function toSafePathname(urlValue) {
47
+ try {
48
+ return decodeURIComponent(new URL(urlValue, 'http://srv.local').pathname);
49
+ } catch (_error) {
50
+ return '/';
51
+ }
52
+ }
53
+
54
+ function resolvePath(root, pathnameValue) {
55
+ const cleaned = pathnameValue.replace(/\\0/g, '');
56
+ const absolute = path.resolve(root, `.${cleaned}`);
57
+ if (!absolute.startsWith(root)) {
58
+ return null;
59
+ }
60
+ return absolute;
61
+ }
62
+
63
+ function injectReload(html) {
64
+ const candidates = ['</body>', '</head>', '</svg>'];
65
+ for (const tag of candidates) {
66
+ const index = html.toLowerCase().lastIndexOf(tag);
67
+ if (index !== -1) {
68
+ return html.slice(0, index) + LIVE_RELOAD_SNIPPET + html.slice(index);
69
+ }
70
+ }
71
+ return html + LIVE_RELOAD_SNIPPET;
72
+ }
73
+
74
+ function renderDirListing({ pathnameValue, entries, style, customCss }) {
75
+ const palette = STYLE_PRESETS[style] || STYLE_PRESETS.midnight;
76
+ const base = pathnameValue.endsWith('/') ? pathnameValue : `${pathnameValue}/`;
77
+
78
+ const list = entries
79
+ .map((entry) => {
80
+ const suffix = entry.isDirectory() ? '/' : '';
81
+ const href = encodeURIComponent(entry.name).replace(/%2F/g, '/') + suffix;
82
+ return `<li><a href="${href}"><span class="name">${entry.name}${suffix}</span><span class="type">${entry.isDirectory() ? 'dir' : 'file'}</span></a></li>`;
83
+ })
84
+ .join('');
85
+
86
+ const up = pathnameValue !== '/' ? '<a class="up" href="..">Go up</a>' : '';
87
+
88
+ return `<!doctype html>
89
+ <html>
90
+ <head>
91
+ <meta charset="utf-8" />
92
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
93
+ <title>Index of ${pathnameValue}</title>
94
+ <style>
95
+ :root {
96
+ --stone-050: #fafaf9;
97
+ --stone-100: #f5f5f4;
98
+ --stone-200: #e7e5e4;
99
+ --stone-300: #d6d3d1;
100
+ --stone-400: #a8a29e;
101
+ --stone-500: #78716c;
102
+ --stone-600: #57534e;
103
+ --stone-700: #44403c;
104
+ --stone-800: #292524;
105
+ --stone-900: #1c1917;
106
+ --stone-950: #0c0a09;
107
+
108
+ --bg: var(--stone-950);
109
+ --bg-raised: var(--stone-900);
110
+ --bg-surface: var(--stone-800);
111
+ --border: var(--stone-800);
112
+ --border-strong: ${palette.borderStrong};
113
+ --text: var(--stone-200);
114
+ --text-muted: var(--stone-400);
115
+ --text-faint: var(--stone-500);
116
+ --text-bright: var(--stone-100);
117
+ --accent: ${palette.accent};
118
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
119
+ --font-mono: "JetBrains Mono", "SF Mono", "Fira Code", "Roboto Mono", "Cascadia Code", monospace;
120
+ --line-height: 1.6;
121
+ --line-height-tight: 1.2;
122
+ }
123
+
124
+ * {
125
+ margin: 0;
126
+ padding: 0;
127
+ box-sizing: border-box;
128
+ border-radius: 0 !important;
129
+ }
130
+
131
+ body {
132
+ background-color: var(--bg);
133
+ color: var(--text);
134
+ font-family: var(--font-sans);
135
+ line-height: var(--line-height);
136
+ min-height: 100vh;
137
+ padding: 2rem;
138
+ }
139
+
140
+ main {
141
+ display: block;
142
+ max-width: 900px;
143
+ margin: 0 auto;
144
+ padding: 1rem;
145
+ background-color: var(--bg-raised);
146
+ border: 1px solid var(--border);
147
+ }
148
+
149
+ h1 {
150
+ margin-bottom: 0.25rem;
151
+ color: var(--text-bright);
152
+ font-family: var(--font-mono);
153
+ font-size: 1.1rem;
154
+ line-height: var(--line-height-tight);
155
+ text-transform: uppercase;
156
+ letter-spacing: 0.04em;
157
+ }
158
+
159
+ .path {
160
+ margin-bottom: 0.75rem;
161
+ color: var(--text-muted);
162
+ font-family: var(--font-mono);
163
+ font-size: 0.9rem;
164
+ }
165
+
166
+ .up {
167
+ display: inline-block;
168
+ margin-bottom: 0.75rem;
169
+ color: var(--accent);
170
+ text-decoration: none;
171
+ border: 1px solid var(--border-strong);
172
+ padding: 0.3rem 0.55rem;
173
+ font-family: var(--font-mono);
174
+ transition: background-color 0.18s ease, color 0.18s ease;
175
+ }
176
+
177
+ .up:hover {
178
+ background-color: var(--accent);
179
+ color: var(--bg);
180
+ }
181
+
182
+ ul {
183
+ list-style: none;
184
+ border: 1px solid var(--border);
185
+ }
186
+
187
+ li a {
188
+ display: flex;
189
+ justify-content: space-between;
190
+ align-items: center;
191
+ width: 100%;
192
+ padding: 0.65rem 0.75rem;
193
+ text-decoration: none;
194
+ color: var(--text);
195
+ border-bottom: 1px solid var(--border);
196
+ font-family: var(--font-mono);
197
+ transition: background-color 0.18s ease, color 0.18s ease;
198
+ }
199
+
200
+ li:last-child a {
201
+ border-bottom: 0;
202
+ }
203
+
204
+ li a:hover {
205
+ background-color: var(--bg-surface);
206
+ color: var(--text-bright);
207
+ }
208
+
209
+ .name {
210
+ max-width: 80%;
211
+ overflow: hidden;
212
+ text-overflow: ellipsis;
213
+ white-space: nowrap;
214
+ }
215
+
216
+ .type {
217
+ color: var(--text-faint);
218
+ text-transform: uppercase;
219
+ font-size: 0.72rem;
220
+ letter-spacing: 0.07em;
221
+ }
222
+
223
+ @media (max-width: 768px) {
224
+ body {
225
+ padding: 1rem;
226
+ }
227
+
228
+ main {
229
+ padding: 0.8rem;
230
+ }
231
+
232
+ li a {
233
+ padding: 0.55rem 0.6rem;
234
+ }
235
+ }
236
+
237
+ ${customCss || ''}
238
+ </style>
239
+ </head>
240
+ <body>
241
+ <main>
242
+ <h1>srv</h1>
243
+ <div class="path">Index of ${base}</div>
244
+ ${up}
245
+ <ul>${list}</ul>
246
+ </main>
247
+ </body>
248
+ </html>`;
249
+ }
250
+
251
+ function getCompressionMiddleware() {
252
+ const middleware = compression();
253
+ return (req, res) =>
254
+ new Promise((resolve, reject) => {
255
+ middleware(req, res, (error) => {
256
+ if (error) reject(error);
257
+ else resolve();
258
+ });
259
+ });
260
+ }
261
+
262
+ async function createSrvServer(options) {
263
+ const root = path.resolve(options.root);
264
+ const compress = getCompressionMiddleware();
265
+ const clients = new Set();
266
+
267
+ let customCss = '';
268
+ if (options.styleCss) {
269
+ customCss = await fsp.readFile(path.resolve(options.styleCss), 'utf8');
270
+ }
271
+
272
+ const requestHandler = async (req, res) => {
273
+ const start = Date.now();
274
+ const method = req.method || 'GET';
275
+ const pathnameValue = toSafePathname(req.url || '/');
276
+
277
+ try {
278
+ if (options.cors) {
279
+ res.setHeader('Access-Control-Allow-Origin', '*');
280
+ res.setHeader('Access-Control-Allow-Headers', '*');
281
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
282
+ }
283
+
284
+ if (req.method === 'OPTIONS') {
285
+ res.statusCode = 204;
286
+ res.end();
287
+ return;
288
+ }
289
+
290
+ await compress(req, res);
291
+
292
+ let filePath = resolvePath(root, pathnameValue);
293
+ if (!filePath) {
294
+ res.statusCode = 403;
295
+ res.end('Forbidden');
296
+ return;
297
+ }
298
+
299
+ let stat;
300
+ try {
301
+ stat = await fsp.stat(filePath);
302
+ } catch (error) {
303
+ if (options.single) {
304
+ filePath = path.join(root, 'index.html');
305
+ stat = await fsp.stat(filePath);
306
+ } else {
307
+ res.statusCode = 404;
308
+ res.end('Not found');
309
+ return;
310
+ }
311
+ }
312
+
313
+ if (stat.isDirectory()) {
314
+ const indexPath = path.join(filePath, 'index.html');
315
+ try {
316
+ const indexStat = await fsp.stat(indexPath);
317
+ if (indexStat.isFile()) {
318
+ filePath = indexPath;
319
+ stat = indexStat;
320
+ }
321
+ } catch (_error) {
322
+ if (!options.directoryListing) {
323
+ res.statusCode = 404;
324
+ res.end('Not found');
325
+ return;
326
+ }
327
+
328
+ const entries = await fsp.readdir(filePath, { withFileTypes: true });
329
+ entries.sort((a, b) => {
330
+ if (a.isDirectory() && !b.isDirectory()) return -1;
331
+ if (!a.isDirectory() && b.isDirectory()) return 1;
332
+ return a.name.localeCompare(b.name);
333
+ });
334
+
335
+ const html = renderDirListing({
336
+ pathnameValue,
337
+ entries,
338
+ style: options.style,
339
+ customCss,
340
+ });
341
+ res.setHeader('content-type', 'text/html; charset=utf-8');
342
+ res.statusCode = 200;
343
+ res.end(injectReload(html));
344
+ return;
345
+ }
346
+ }
347
+
348
+ if (!stat.isFile()) {
349
+ res.statusCode = 404;
350
+ res.end('Not found');
351
+ return;
352
+ }
353
+
354
+ const ext = path.extname(filePath).toLowerCase();
355
+ const contentType = mime.contentType(ext) || 'application/octet-stream';
356
+ res.setHeader('content-type', contentType);
357
+ res.setHeader('cache-control', 'no-cache');
358
+
359
+ if (['.html', '.htm', '.xhtml', '.php', '.svg'].includes(ext)) {
360
+ const raw = await fsp.readFile(filePath, 'utf8');
361
+ const html = injectReload(raw);
362
+ res.statusCode = 200;
363
+ res.end(html);
364
+ return;
365
+ }
366
+
367
+ res.statusCode = 200;
368
+ await pipeline(fs.createReadStream(filePath), res);
369
+ } catch (error) {
370
+ res.statusCode = 500;
371
+ res.end('Internal server error');
372
+ if (options.logLevel >= 1) {
373
+ console.error('[srv] request error:', error.message);
374
+ }
375
+ } finally {
376
+ if (!options.noRequestLogging) {
377
+ const statusCode = res.statusCode;
378
+ const elapsed = Date.now() - start;
379
+ const sourceIp = (req.socket.remoteAddress || '-').replace('::ffff:', '');
380
+ const now = new Date();
381
+ const formattedTime = `${now.toLocaleDateString()} ${now.toLocaleTimeString()}`;
382
+ const methodColor = method === 'GET' ? 'cyan' : 'magenta';
383
+
384
+ console.log(
385
+ chalk.dim(formattedTime),
386
+ chalk.yellow(sourceIp),
387
+ chalk[methodColor](`${method} ${pathnameValue}`),
388
+ chalk[statusCode < 400 ? 'green' : 'red'](`-> ${statusCode} (${elapsed}ms)`),
389
+ );
390
+ }
391
+ }
392
+ };
393
+
394
+ const useSsl = options.sslCert && options.sslKey;
395
+ let server;
396
+
397
+ if (useSsl) {
398
+ const cert = await fsp.readFile(path.resolve(options.sslCert));
399
+ const key = await fsp.readFile(path.resolve(options.sslKey));
400
+ const passphrase = options.sslPass
401
+ ? await fsp.readFile(path.resolve(options.sslPass), 'utf8')
402
+ : undefined;
403
+ server = https.createServer({ cert, key, passphrase }, (req, res) => {
404
+ requestHandler(req, res).catch((error) => {
405
+ res.statusCode = 500;
406
+ res.end('Internal server error');
407
+ if (options.logLevel >= 1) {
408
+ console.error('[srv] handler error:', error.message);
409
+ }
410
+ });
411
+ });
412
+ } else {
413
+ server = http.createServer((req, res) => {
414
+ requestHandler(req, res).catch((error) => {
415
+ res.statusCode = 500;
416
+ res.end('Internal server error');
417
+ if (options.logLevel >= 1) {
418
+ console.error('[srv] handler error:', error.message);
419
+ }
420
+ });
421
+ });
422
+ }
423
+
424
+ const wss = new WebSocketServer({ server, path: '/__srv_ws' });
425
+ wss.on('connection', (socket) => {
426
+ clients.add(socket);
427
+ socket.on('close', () => clients.delete(socket));
428
+ });
429
+
430
+ const watchPaths = [root, ...(options.watch || []).map((x) => path.resolve(x))];
431
+ const watcher = chokidar.watch(watchPaths, {
432
+ ignoreInitial: true,
433
+ ignored: options.ignore && options.ignore.length > 0 ? options.ignore : undefined,
434
+ });
435
+
436
+ const sendReload = (changePath) => {
437
+ const isCss = path.extname(changePath).toLowerCase() === '.css' && !options.noCssInject;
438
+ const message = isCss ? 'refreshcss' : 'reload';
439
+ for (const client of clients) {
440
+ if (client.readyState === 1) {
441
+ client.send(message);
442
+ }
443
+ }
444
+ if (options.logLevel >= 2) {
445
+ console.log(`[srv] ${isCss ? 'css refresh' : 'reload'}: ${changePath}`);
446
+ }
447
+ };
448
+
449
+ watcher.on('change', sendReload);
450
+ watcher.on('add', sendReload);
451
+ watcher.on('unlink', sendReload);
452
+ watcher.on('addDir', sendReload);
453
+ watcher.on('unlinkDir', sendReload);
454
+
455
+ const closeAll = async () => {
456
+ await watcher.close();
457
+ wss.close();
458
+ await new Promise((resolve) => server.close(resolve));
459
+ };
460
+
461
+ process.on('SIGINT', async () => {
462
+ console.log('\n[srv] shutting down...');
463
+ await closeAll();
464
+ process.exit(0);
465
+ });
466
+
467
+ await new Promise((resolve, reject) => {
468
+ server.once('error', reject);
469
+ server.listen(options.port, options.host, resolve);
470
+ });
471
+
472
+ const address = server.address();
473
+ const port = typeof address === 'object' && address ? address.port : options.port;
474
+ const networkIp = getNetworkAddress();
475
+ const protocol = useSsl ? 'https' : 'http';
476
+ const network = networkIp ? `${protocol}://${networkIp}:${port}` : undefined;
477
+
478
+ return { server, port, closeAll, network };
479
+ }
480
+
481
+ module.exports = {
482
+ createSrvServer,
483
+ };