@chenpu17/serve-here 1.0.0 → 1.0.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
@@ -8,6 +8,8 @@ Serve any local directory over HTTP with a single command.
8
8
  立即托管当前目录或指定目录。
9
9
  - Automatic `index.html` support plus clean directory listings.
10
10
  自动识别 `index.html`,无首页时提供整洁的目录列表。
11
+ - Safe redirects for non-ASCII directory names (e.g. 中文路径).
12
+ 支持中文等非 ASCII 路径的安全重定向。
11
13
  - Directory rows include file size and last modified time.
12
14
  列表项展示文件大小与最近修改时间。
13
15
  - Works as a global install or `npx` one-off.
package/bin/serve-here.js CHANGED
@@ -4,12 +4,212 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const process = require('process');
7
+ const { spawn } = require('child_process');
7
8
 
8
9
  const { createStaticServer } = require('../src/server');
9
10
  const pkg = require('../package.json');
10
11
 
11
12
  const DEFAULT_PORT = 8080;
12
13
  const DEFAULT_HOST = '0.0.0.0';
14
+ const PID_DIR = path.join(os.homedir(), '.serve-here');
15
+ const LOG_DIR = path.join(os.homedir(), '.serve-here', 'logs');
16
+
17
+ function ensureDirectories() {
18
+ if (!fs.existsSync(PID_DIR)) {
19
+ fs.mkdirSync(PID_DIR, { recursive: true });
20
+ }
21
+ if (!fs.existsSync(LOG_DIR)) {
22
+ fs.mkdirSync(LOG_DIR, { recursive: true });
23
+ }
24
+ }
25
+
26
+ function getPidFile(port) {
27
+ return path.join(PID_DIR, `serve-here-${port}.pid`);
28
+ }
29
+
30
+ function getLogFile(port) {
31
+ return path.join(LOG_DIR, `serve-here-${port}.log`);
32
+ }
33
+
34
+ function readPidFile(port) {
35
+ const pidFile = getPidFile(port);
36
+ if (fs.existsSync(pidFile)) {
37
+ try {
38
+ const content = fs.readFileSync(pidFile, 'utf8').trim();
39
+ const lines = content.split('\n');
40
+ const pid = parseInt(lines[0], 10);
41
+ const rootDir = lines[1] || '';
42
+ return { pid, rootDir };
43
+ } catch (error) {
44
+ return null;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function writePidFile(port, pid, rootDir) {
51
+ const pidFile = getPidFile(port);
52
+ fs.writeFileSync(pidFile, `${pid}\n${rootDir}`);
53
+ }
54
+
55
+ function removePidFile(port) {
56
+ const pidFile = getPidFile(port);
57
+ if (fs.existsSync(pidFile)) {
58
+ fs.unlinkSync(pidFile);
59
+ }
60
+ }
61
+
62
+ function isProcessRunning(pid) {
63
+ try {
64
+ process.kill(pid, 0);
65
+ return true;
66
+ } catch (error) {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function startDaemon(options) {
72
+ ensureDirectories();
73
+
74
+ const rootDir = path.resolve(options.directory || process.cwd());
75
+ const port = options.port || DEFAULT_PORT;
76
+ const host = options.host || DEFAULT_HOST;
77
+
78
+ // Check if already running
79
+ const pidInfo = readPidFile(port);
80
+ if (pidInfo && isProcessRunning(pidInfo.pid)) {
81
+ console.error(`Error: Server already running on port ${port} (PID: ${pidInfo.pid})`);
82
+ console.error(`Serving: ${pidInfo.rootDir}`);
83
+ process.exit(1);
84
+ }
85
+
86
+ // Validate directory
87
+ let stats;
88
+ try {
89
+ stats = fs.statSync(rootDir);
90
+ } catch (error) {
91
+ console.error(`Error: directory "${rootDir}" does not exist or is not accessible.`);
92
+ process.exit(1);
93
+ }
94
+ if (!stats.isDirectory()) {
95
+ console.error(`Error: path "${rootDir}" is not a directory.`);
96
+ process.exit(1);
97
+ }
98
+
99
+ const logFile = getLogFile(port);
100
+ const out = fs.openSync(logFile, 'a');
101
+ const err = fs.openSync(logFile, 'a');
102
+
103
+ const child = spawn(process.execPath, [__filename, '--daemon-child', '-d', rootDir, '-p', String(port), '-H', host], {
104
+ detached: true,
105
+ stdio: ['ignore', out, err],
106
+ env: process.env
107
+ });
108
+
109
+ child.unref();
110
+
111
+ writePidFile(port, child.pid, rootDir);
112
+
113
+ console.log(`Server started in background (PID: ${child.pid})`);
114
+ console.log(`Serving: ${rootDir}`);
115
+ console.log(`Listening on port: ${port}`);
116
+ console.log(`Log file: ${logFile}`);
117
+ console.log(`\nTo stop: serve-here --stop -p ${port}`);
118
+ }
119
+
120
+ function stopDaemon(port) {
121
+ const pidInfo = readPidFile(port);
122
+ if (!pidInfo) {
123
+ console.error(`No server running on port ${port}`);
124
+ process.exit(1);
125
+ }
126
+
127
+ if (!isProcessRunning(pidInfo.pid)) {
128
+ console.log(`Server (PID: ${pidInfo.pid}) is not running, cleaning up...`);
129
+ removePidFile(port);
130
+ process.exit(0);
131
+ }
132
+
133
+ try {
134
+ process.kill(pidInfo.pid, 'SIGTERM');
135
+ console.log(`Stopping server (PID: ${pidInfo.pid})...`);
136
+
137
+ // Wait for process to stop
138
+ let attempts = 0;
139
+ const checkInterval = setInterval(() => {
140
+ attempts++;
141
+ if (!isProcessRunning(pidInfo.pid)) {
142
+ clearInterval(checkInterval);
143
+ removePidFile(port);
144
+ console.log('Server stopped.');
145
+ process.exit(0);
146
+ } else if (attempts >= 10) {
147
+ clearInterval(checkInterval);
148
+ console.error('Server did not stop gracefully, force killing...');
149
+ try {
150
+ process.kill(pidInfo.pid, 'SIGKILL');
151
+ } catch (e) {}
152
+ removePidFile(port);
153
+ process.exit(0);
154
+ }
155
+ }, 500);
156
+ } catch (error) {
157
+ console.error(`Failed to stop server: ${error.message}`);
158
+ removePidFile(port);
159
+ process.exit(1);
160
+ }
161
+ }
162
+
163
+ function showStatus(port) {
164
+ if (port) {
165
+ const pidInfo = readPidFile(port);
166
+ if (!pidInfo) {
167
+ console.log(`No server running on port ${port}`);
168
+ return;
169
+ }
170
+
171
+ const running = isProcessRunning(pidInfo.pid);
172
+ console.log(`Port ${port}:`);
173
+ console.log(` PID: ${pidInfo.pid}`);
174
+ console.log(` Status: ${running ? 'running' : 'stopped'}`);
175
+ console.log(` Directory: ${pidInfo.rootDir}`);
176
+ console.log(` Log: ${getLogFile(port)}`);
177
+
178
+ if (!running) {
179
+ removePidFile(port);
180
+ }
181
+ } else {
182
+ // Show all
183
+ ensureDirectories();
184
+ const files = fs.readdirSync(PID_DIR).filter(f => f.endsWith('.pid'));
185
+
186
+ if (files.length === 0) {
187
+ console.log('No servers running.');
188
+ return;
189
+ }
190
+
191
+ console.log('Running servers:\n');
192
+ for (const file of files) {
193
+ const match = file.match(/serve-here-(\d+)\.pid/);
194
+ if (match) {
195
+ const p = parseInt(match[1], 10);
196
+ const pidInfo = readPidFile(p);
197
+ if (pidInfo) {
198
+ const running = isProcessRunning(pidInfo.pid);
199
+ console.log(`Port ${p}:`);
200
+ console.log(` PID: ${pidInfo.pid}`);
201
+ console.log(` Status: ${running ? 'running' : 'stopped'}`);
202
+ console.log(` Directory: ${pidInfo.rootDir}`);
203
+ console.log('');
204
+
205
+ if (!running) {
206
+ removePidFile(p);
207
+ }
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
13
213
 
14
214
  function main() {
15
215
  try {
@@ -25,6 +225,22 @@ function main() {
25
225
  process.exit(0);
26
226
  }
27
227
 
228
+ // Handle daemon commands
229
+ if (options.stop) {
230
+ stopDaemon(options.port || DEFAULT_PORT);
231
+ return;
232
+ }
233
+
234
+ if (options.status) {
235
+ showStatus(options.port);
236
+ return;
237
+ }
238
+
239
+ if (options.daemon && !options.daemonChild) {
240
+ startDaemon(options);
241
+ return;
242
+ }
243
+
28
244
  const rootDir = path.resolve(options.directory || process.cwd());
29
245
 
30
246
  let stats;
@@ -44,8 +260,16 @@ function main() {
44
260
  const port = options.port || DEFAULT_PORT;
45
261
  const host = options.host || DEFAULT_HOST;
46
262
 
263
+ if (options.daemonChild) {
264
+ ensureDirectories();
265
+ process.on('exit', () => removePidFile(port));
266
+ }
267
+
47
268
  server.on('error', error => {
48
269
  console.error('Failed to start server:', error.message);
270
+ if (options.daemonChild) {
271
+ removePidFile(port);
272
+ }
49
273
  process.exit(1);
50
274
  });
51
275
 
@@ -56,10 +280,18 @@ function main() {
56
280
  ...formatListeningAddresses(host, port)
57
281
  ];
58
282
  console.log(messageLines.join('\n '));
283
+
284
+ // Write PID file when running as daemon child
285
+ if (options.daemonChild) {
286
+ writePidFile(port, process.pid, rootDir);
287
+ }
59
288
  });
60
289
 
61
290
  const shutdown = () => {
62
291
  console.log('\nShutting down...');
292
+ if (options.daemonChild) {
293
+ removePidFile(port);
294
+ }
63
295
  server.close(() => process.exit(0));
64
296
  };
65
297
 
@@ -77,7 +309,11 @@ function parseArguments(args) {
77
309
  port: undefined,
78
310
  host: undefined,
79
311
  help: false,
80
- version: false
312
+ version: false,
313
+ daemon: false,
314
+ daemonChild: false,
315
+ stop: false,
316
+ status: false
81
317
  };
82
318
 
83
319
  for (let i = 0; i < args.length; i += 1) {
@@ -105,6 +341,19 @@ function parseArguments(args) {
105
341
  case '--host':
106
342
  options.host = requireValue(args, ++i, '--host');
107
343
  break;
344
+ case '-D':
345
+ case '--daemon':
346
+ options.daemon = true;
347
+ break;
348
+ case '--daemon-child':
349
+ options.daemonChild = true;
350
+ break;
351
+ case '--stop':
352
+ options.stop = true;
353
+ break;
354
+ case '--status':
355
+ options.status = true;
356
+ break;
108
357
  default:
109
358
  if (arg.startsWith('-')) {
110
359
  throw new Error(`Unknown option: ${arg}`);
@@ -167,8 +416,19 @@ Options:
167
416
  -d, --dir <path> Directory to serve (defaults to current working directory)
168
417
  -p, --port <number> Port to listen on (default: ${DEFAULT_PORT})
169
418
  -H, --host <address> Hostname or IP to bind (default: ${DEFAULT_HOST})
419
+ -D, --daemon Run as a background daemon (does not occupy terminal)
420
+ --stop Stop a running daemon (use with -p to specify port)
421
+ --status Show status of running daemon(s)
170
422
  -h, --help Show this help message
171
- -V, --version Show version`);
423
+ -V, --version Show version
424
+
425
+ Examples:
426
+ serve-here Start server in foreground on port 8080
427
+ serve-here -D Start server as daemon on port 8080
428
+ serve-here -D -p 3000 Start daemon on port 3000
429
+ serve-here --stop Stop daemon on port 8080
430
+ serve-here --stop -p 3000 Stop daemon on port 3000
431
+ serve-here --status Show all running daemons`);
172
432
  }
173
433
 
174
434
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenpu17/serve-here",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "A minimal CLI to serve static files from a directory over HTTP.",
5
5
  "bin": {
6
6
  "serve-here": "bin/serve-here.js"
package/src/server.js CHANGED
@@ -30,127 +30,162 @@ function createStaticServer({
30
30
  ? resolvedRoot
31
31
  : `${resolvedRoot}${path.sep}`;
32
32
 
33
- const server = http.createServer(async (req, res) => {
33
+ const server = http.createServer((req, res) => {
34
34
  const startTime = Date.now();
35
35
 
36
- if (!req.url) {
37
- sendError(res, 400, 'Bad Request');
38
- return;
39
- }
40
-
41
- if (req.method !== 'GET' && req.method !== 'HEAD') {
42
- res.setHeader('Allow', 'GET, HEAD');
43
- sendError(res, 405, 'Method Not Allowed');
44
- logRequest(logger, req, res.statusCode, startTime);
45
- return;
46
- }
36
+ void (async () => {
37
+ if (!req.url) {
38
+ sendError(res, 400, 'Bad Request', req.method === 'HEAD');
39
+ return;
40
+ }
47
41
 
48
- let decodedPath;
49
- try {
50
- const url = new URL(req.url, 'http://localhost');
51
- decodedPath = decodeURIComponent(url.pathname);
52
- } catch (error) {
53
- sendError(res, 400, 'Bad Request');
54
- logRequest(logger, req, res.statusCode, startTime, error);
55
- return;
56
- }
42
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
43
+ res.setHeader('Allow', 'GET, HEAD');
44
+ sendError(res, 405, 'Method Not Allowed', false);
45
+ logRequest(logger, req, res.statusCode, startTime);
46
+ return;
47
+ }
57
48
 
58
- const candidateSegments = decodedPath
59
- .split('/')
60
- .filter(segment => segment && segment !== '.');
61
- const resolvedPath = path.resolve(resolvedRoot, ...candidateSegments);
49
+ let decodedPath;
50
+ let requestPathname;
51
+ let requestSearch;
52
+ try {
53
+ const url = new URL(req.url, 'http://localhost');
54
+ requestPathname = url.pathname;
55
+ requestSearch = url.search;
56
+ decodedPath = decodeURIComponent(requestPathname);
57
+ } catch (error) {
58
+ sendError(res, 400, 'Bad Request', req.method === 'HEAD');
59
+ logRequest(logger, req, res.statusCode, startTime, error);
60
+ return;
61
+ }
62
62
 
63
- if (!resolvedPath.startsWith(rootWithSep) && resolvedPath !== resolvedRoot) {
64
- sendError(res, 403, 'Forbidden');
65
- logRequest(logger, req, res.statusCode, startTime);
66
- return;
67
- }
63
+ const candidateSegments = decodedPath
64
+ .split('/')
65
+ .filter(segment => segment && segment !== '.');
66
+ const resolvedPath = path.resolve(resolvedRoot, ...candidateSegments);
68
67
 
69
- let stats;
70
- try {
71
- stats = await stat(resolvedPath);
72
- } catch (error) {
73
- if (error.code === 'ENOENT') {
74
- sendError(res, 404, 'Not Found');
75
- } else {
76
- sendError(res, 500, 'Internal Server Error');
77
- logger.error('Error reading path', resolvedPath, error);
68
+ if (!resolvedPath.startsWith(rootWithSep) && resolvedPath !== resolvedRoot) {
69
+ sendError(res, 403, 'Forbidden', req.method === 'HEAD');
70
+ logRequest(logger, req, res.statusCode, startTime);
71
+ return;
78
72
  }
79
- logRequest(logger, req, res.statusCode, startTime, error);
80
- return;
81
- }
82
73
 
83
- if (stats.isDirectory()) {
84
- if (!decodedPath.endsWith('/')) {
85
- // Align with browser expectations for relative asset loading.
86
- res.statusCode = 301;
87
- res.setHeader('Location', `${decodedPath}/`);
88
- res.end();
89
- logRequest(logger, req, res.statusCode, startTime);
74
+ let stats;
75
+ try {
76
+ stats = await stat(resolvedPath);
77
+ } catch (error) {
78
+ if (error.code === 'ENOENT') {
79
+ sendError(res, 404, 'Not Found', req.method === 'HEAD');
80
+ } else {
81
+ sendError(res, 500, 'Internal Server Error', req.method === 'HEAD');
82
+ logger.error('Error reading path', resolvedPath, error);
83
+ }
84
+ logRequest(logger, req, res.statusCode, startTime, error);
90
85
  return;
91
86
  }
92
87
 
93
- for (const indexFile of indexFiles) {
94
- const candidate = path.join(resolvedPath, indexFile);
95
- try {
96
- const indexStats = await stat(candidate);
97
- if (indexStats.isFile()) {
98
- await serveFile(res, candidate, indexStats, req.method === 'HEAD');
88
+ if (stats.isDirectory()) {
89
+ if (!requestPathname.endsWith('/')) {
90
+ // Align with browser expectations for relative asset loading.
91
+ const location = `${requestPathname}/${requestSearch}`;
92
+ try {
93
+ // Use the encoded pathname for headers (Node rejects non-ASCII in Location).
94
+ res.statusCode = 301;
95
+ res.setHeader('Location', location);
96
+ res.end();
99
97
  logRequest(logger, req, res.statusCode, startTime);
100
98
  return;
99
+ } catch (error) {
100
+ sendError(res, 400, 'Bad Request', req.method === 'HEAD');
101
+ logRequest(logger, req, res.statusCode, startTime, error);
102
+ return;
101
103
  }
102
- } catch (error) {
103
- if (error.code !== 'ENOENT') {
104
- logger.error('Error reading index file', candidate, error);
104
+ }
105
+
106
+ for (const indexFile of indexFiles) {
107
+ const candidate = path.join(resolvedPath, indexFile);
108
+ try {
109
+ const indexStats = await stat(candidate);
110
+ if (indexStats.isFile()) {
111
+ await serveFile(res, candidate, indexStats, req.method === 'HEAD');
112
+ logRequest(logger, req, res.statusCode, startTime);
113
+ return;
114
+ }
115
+ } catch (error) {
116
+ if (error.code !== 'ENOENT') {
117
+ logger.error('Error reading index file', candidate, error);
118
+ }
105
119
  }
106
120
  }
107
- }
108
121
 
109
- if (!enableDirectoryListing) {
110
- sendError(res, 403, 'Directory listing disabled');
111
- logRequest(logger, req, res.statusCode, startTime);
112
- return;
122
+ if (!enableDirectoryListing) {
123
+ sendError(res, 403, 'Directory listing disabled', req.method === 'HEAD');
124
+ logRequest(logger, req, res.statusCode, startTime);
125
+ return;
126
+ }
127
+
128
+ try {
129
+ await serveDirectoryListing(
130
+ res,
131
+ decodedPath,
132
+ resolvedPath,
133
+ req.method === 'HEAD'
134
+ );
135
+ logRequest(logger, req, res.statusCode, startTime);
136
+ return;
137
+ } catch (error) {
138
+ sendError(res, 500, 'Internal Server Error', req.method === 'HEAD');
139
+ logger.error('Error generating directory listing', resolvedPath, error);
140
+ logRequest(logger, req, res.statusCode, startTime, error);
141
+ return;
142
+ }
113
143
  }
114
144
 
115
- try {
116
- await serveDirectoryListing(
117
- res,
118
- decodedPath,
119
- resolvedPath,
120
- req.method === 'HEAD'
121
- );
145
+ if (stats.isFile()) {
146
+ try {
147
+ await serveFile(res, resolvedPath, stats, req.method === 'HEAD');
148
+ } catch (error) {
149
+ logger.error('Error serving file', resolvedPath, error);
150
+ // Stream errors may happen after headers were sent.
151
+ }
122
152
  logRequest(logger, req, res.statusCode, startTime);
123
153
  return;
124
- } catch (error) {
125
- sendError(res, 500, 'Internal Server Error');
126
- logger.error('Error generating directory listing', resolvedPath, error);
127
- logRequest(logger, req, res.statusCode, startTime, error);
128
- return;
129
154
  }
130
- }
131
155
 
132
- if (stats.isFile()) {
133
- try {
134
- await serveFile(res, resolvedPath, stats, req.method === 'HEAD');
135
- } catch (error) {
136
- logger.error('Error serving file', resolvedPath, error);
137
- // Stream errors may happen after headers were sent.
138
- }
156
+ sendError(res, 403, 'Forbidden', req.method === 'HEAD');
139
157
  logRequest(logger, req, res.statusCode, startTime);
140
- return;
141
- }
142
-
143
- sendError(res, 403, 'Forbidden');
144
- logRequest(logger, req, res.statusCode, startTime);
158
+ })().catch(error => {
159
+ if (!res.headersSent) {
160
+ sendError(res, 500, 'Internal Server Error', req.method === 'HEAD');
161
+ } else {
162
+ res.destroy();
163
+ }
164
+ logger.error('Unhandled request error', error);
165
+ logRequest(logger, req, res.statusCode || 500, startTime, error);
166
+ });
145
167
  });
146
168
 
147
169
  return server;
148
170
  }
149
171
 
150
- function sendError(res, statusCode, message) {
172
+ function sendError(res, statusCode, message, headOnly = false) {
173
+ if (res.headersSent) {
174
+ if (!res.writableEnded) {
175
+ res.end();
176
+ }
177
+ return;
178
+ }
179
+
151
180
  res.statusCode = statusCode;
152
181
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
153
182
  res.setHeader('Cache-Control', 'no-store');
183
+
184
+ if (headOnly) {
185
+ res.end();
186
+ return;
187
+ }
188
+
154
189
  res.end(`${statusCode} ${message}`);
155
190
  }
156
191
 
@@ -173,15 +208,51 @@ async function serveFile(res, filePath, stats, headOnly) {
173
208
  function streamFile(res, filePath) {
174
209
  return new Promise((resolve, reject) => {
175
210
  const stream = fs.createReadStream(filePath);
176
- stream.on('error', error => {
211
+ let settled = false;
212
+
213
+ const settle = (fn, arg) => {
214
+ if (settled) return;
215
+ settled = true;
216
+ cleanup();
217
+ fn(arg);
218
+ };
219
+
220
+ const onResponseClose = () => {
221
+ if (!stream.destroyed) {
222
+ stream.destroy();
223
+ }
224
+ settle(resolve);
225
+ };
226
+
227
+ const onResponseError = error => {
228
+ if (!stream.destroyed) {
229
+ stream.destroy(error);
230
+ }
231
+ settle(reject, error);
232
+ };
233
+
234
+ const onStreamError = error => {
177
235
  if (!res.headersSent) {
178
236
  sendError(res, 500, 'Internal Server Error');
179
237
  } else {
180
238
  res.destroy(error);
181
239
  }
182
- reject(error);
183
- });
184
- stream.on('end', resolve);
240
+ settle(reject, error);
241
+ };
242
+
243
+ const onStreamEnd = () => settle(resolve);
244
+
245
+ const cleanup = () => {
246
+ res.removeListener('close', onResponseClose);
247
+ res.removeListener('error', onResponseError);
248
+ stream.removeListener('error', onStreamError);
249
+ stream.removeListener('end', onStreamEnd);
250
+ };
251
+
252
+ res.on('close', onResponseClose);
253
+ res.on('error', onResponseError);
254
+ stream.on('error', onStreamError);
255
+ stream.on('end', onStreamEnd);
185
256
  stream.pipe(res);
186
257
  });
187
258
  }