@chenpu17/serve-here 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Chen Pu
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,62 @@
1
+ # serve-here
2
+
3
+ Serve any local directory over HTTP with a single command.
4
+ 用一条命令把任意本地目录通过 HTTP 暴露出去。
5
+
6
+ ## Features | 功能特点
7
+ - Instant static hosting for the current or specified directory.
8
+ 立即托管当前目录或指定目录。
9
+ - Automatic `index.html` support plus clean directory listings.
10
+ 自动识别 `index.html`,无首页时提供整洁的目录列表。
11
+ - Directory rows include file size and last modified time.
12
+ 列表项展示文件大小与最近修改时间。
13
+ - Works as a global install or `npx` one-off.
14
+ 支持全局安装或通过 `npx` 临时使用。
15
+
16
+ ## Installation | 安装
17
+
18
+ ```sh
19
+ npm install -g @chenpu17/serve-here
20
+ ```
21
+
22
+ Or run it ad‑hoc without installing globally:
23
+ 或者临时使用:
24
+
25
+ ```sh
26
+ npx @chenpu17/serve-here
27
+ ```
28
+
29
+ ## Usage | 使用方式
30
+
31
+ ```sh
32
+ serve-here [options] [directory]
33
+ ```
34
+
35
+ - Installed globally, invoke via `serve-here`; with `npx`, run `npx @chenpu17/serve-here`.
36
+ 全局安装后直接使用 `serve-here`;临时使用时运行 `npx @chenpu17/serve-here`。
37
+
38
+ - `directory`: Directory to share; defaults to the current working directory.
39
+ `directory`:要共享的目录,默认使用当前工作目录。
40
+ - `-d, --dir <path>`: Explicit directory override.
41
+ `-d, --dir <path>`:显式指定要共享的目录。
42
+ - `-p, --port <number>`: Port to listen on (default `8080`).
43
+ `-p, --port <number>`:设置监听端口(默认 `8080`)。
44
+ - `-H, --host <address>`: Host/IP to bind (default `0.0.0.0`).
45
+ `-H, --host <address>`:指定绑定的主机或 IP(默认 `0.0.0.0`)。
46
+ - `-h, --help`: Show help.
47
+ `-h, --help`:查看帮助信息。
48
+ - `-V, --version`: Print version.
49
+ `-V, --version`:查看版本号。
50
+
51
+ After startup you’ll see the bound addresses in the terminal. Opening them in a browser displays your static files or a table view of the directory contents.
52
+ 启动后终端会打印可访问的地址,浏览器打开即可查看静态文件;若目录中没有 `index.html`,将显示带有文件大小和修改时间的目录表格。
53
+
54
+ ## Development | 开发
55
+
56
+ ```sh
57
+ npm install
58
+ npm start
59
+ ```
60
+
61
+ `npm start` serves the current project directory so you can test quickly with a browser or `curl`.
62
+ `npm start` 会把当前项目目录作为静态资源根目录,方便通过浏览器或 `curl` 快速验证。
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const process = require('process');
7
+
8
+ const { createStaticServer } = require('../src/server');
9
+ const pkg = require('../package.json');
10
+
11
+ const DEFAULT_PORT = 8080;
12
+ const DEFAULT_HOST = '0.0.0.0';
13
+
14
+ function main() {
15
+ try {
16
+ const options = parseArguments(process.argv.slice(2));
17
+
18
+ if (options.help) {
19
+ printHelp();
20
+ process.exit(0);
21
+ }
22
+
23
+ if (options.version) {
24
+ console.log(pkg.version);
25
+ process.exit(0);
26
+ }
27
+
28
+ const rootDir = path.resolve(options.directory || process.cwd());
29
+
30
+ let stats;
31
+ try {
32
+ stats = fs.statSync(rootDir);
33
+ } catch (error) {
34
+ console.error(`Error: directory "${rootDir}" does not exist or is not accessible.`);
35
+ process.exit(1);
36
+ }
37
+
38
+ if (!stats.isDirectory()) {
39
+ console.error(`Error: path "${rootDir}" is not a directory.`);
40
+ process.exit(1);
41
+ }
42
+
43
+ const server = createStaticServer({ rootDir });
44
+ const port = options.port || DEFAULT_PORT;
45
+ const host = options.host || DEFAULT_HOST;
46
+
47
+ server.on('error', error => {
48
+ console.error('Failed to start server:', error.message);
49
+ process.exit(1);
50
+ });
51
+
52
+ server.listen(port, host, () => {
53
+ const messageLines = [
54
+ `Serving ${rootDir}`,
55
+ `Listening on:`,
56
+ ...formatListeningAddresses(host, port)
57
+ ];
58
+ console.log(messageLines.join('\n '));
59
+ });
60
+
61
+ const shutdown = () => {
62
+ console.log('\nShutting down...');
63
+ server.close(() => process.exit(0));
64
+ };
65
+
66
+ process.on('SIGINT', shutdown);
67
+ process.on('SIGTERM', shutdown);
68
+ } catch (error) {
69
+ console.error(error.message);
70
+ process.exit(1);
71
+ }
72
+ }
73
+
74
+ function parseArguments(args) {
75
+ const options = {
76
+ directory: undefined,
77
+ port: undefined,
78
+ host: undefined,
79
+ help: false,
80
+ version: false
81
+ };
82
+
83
+ for (let i = 0; i < args.length; i += 1) {
84
+ const arg = args[i];
85
+
86
+ switch (arg) {
87
+ case '-h':
88
+ case '--help':
89
+ options.help = true;
90
+ break;
91
+ case '-V':
92
+ case '--version':
93
+ options.version = true;
94
+ break;
95
+ case '-d':
96
+ case '--dir':
97
+ case '--directory':
98
+ options.directory = requireValue(args, ++i, '--dir');
99
+ break;
100
+ case '-p':
101
+ case '--port':
102
+ options.port = parsePort(requireValue(args, ++i, '--port'));
103
+ break;
104
+ case '-H':
105
+ case '--host':
106
+ options.host = requireValue(args, ++i, '--host');
107
+ break;
108
+ default:
109
+ if (arg.startsWith('-')) {
110
+ throw new Error(`Unknown option: ${arg}`);
111
+ }
112
+ if (options.directory) {
113
+ throw new Error('Multiple directories specified. Use --dir <path> to set the directory.');
114
+ }
115
+ options.directory = arg;
116
+ break;
117
+ }
118
+ }
119
+
120
+ return options;
121
+ }
122
+
123
+ function requireValue(args, index, flagName) {
124
+ const value = args[index];
125
+ if (value === undefined) {
126
+ throw new Error(`Missing value for ${flagName}`);
127
+ }
128
+ return value;
129
+ }
130
+
131
+ function parsePort(value) {
132
+ const port = Number.parseInt(value, 10);
133
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
134
+ throw new Error(`Invalid port: ${value}`);
135
+ }
136
+ return port;
137
+ }
138
+
139
+ function formatListeningAddresses(host, port) {
140
+ const addresses = [];
141
+
142
+ if (host === '0.0.0.0' || host === '::') {
143
+ addresses.push(`http://localhost:${port}/`);
144
+ const interfaces = os.networkInterfaces();
145
+ for (const iface of Object.values(interfaces)) {
146
+ if (!iface) continue;
147
+ for (const addrInfo of iface) {
148
+ if (addrInfo.internal) continue;
149
+ if (addrInfo.family !== 'IPv4' && addrInfo.family !== 4) continue;
150
+ addresses.push(`http://${addrInfo.address}:${port}/`);
151
+ }
152
+ }
153
+ } else {
154
+ addresses.push(`http://${host}:${port}/`);
155
+ }
156
+
157
+ return [...new Set(addresses)];
158
+ }
159
+
160
+ function printHelp() {
161
+ console.log(`serve-here v${pkg.version}
162
+
163
+ Usage:
164
+ serve-here [options] [directory]
165
+
166
+ Options:
167
+ -d, --dir <path> Directory to serve (defaults to current working directory)
168
+ -p, --port <number> Port to listen on (default: ${DEFAULT_PORT})
169
+ -H, --host <address> Hostname or IP to bind (default: ${DEFAULT_HOST})
170
+ -h, --help Show this help message
171
+ -V, --version Show version`);
172
+ }
173
+
174
+ main();
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@chenpu17/serve-here",
3
+ "version": "1.0.0",
4
+ "description": "A minimal CLI to serve static files from a directory over HTTP.",
5
+ "bin": {
6
+ "serve-here": "bin/serve-here.js"
7
+ },
8
+ "type": "commonjs",
9
+ "scripts": {
10
+ "start": "node bin/serve-here.js"
11
+ },
12
+ "keywords": [
13
+ "cli",
14
+ "http",
15
+ "static",
16
+ "server",
17
+ "serve",
18
+ "directory"
19
+ ],
20
+ "author": "Chen Pu",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/chenpu17/serve-here.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/chenpu17/serve-here/issues"
28
+ },
29
+ "homepage": "https://github.com/chenpu17/serve-here#readme",
30
+ "files": [
31
+ "bin",
32
+ "src",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "engines": {
37
+ "node": ">=14"
38
+ },
39
+ "dependencies": {
40
+ "mime-types": "^2.1.35"
41
+ }
42
+ }
package/src/server.js ADDED
@@ -0,0 +1,332 @@
1
+ const http = require('http');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { lookup } = require('mime-types');
5
+
6
+ const { readdir, stat } = fs.promises;
7
+
8
+ /**
9
+ * Creates an HTTP server that serves static files rooted at the provided directory.
10
+ *
11
+ * @param {object} options
12
+ * @param {string} options.rootDir Absolute or relative directory to serve.
13
+ * @param {string[]} [options.indexFiles] Index files to try when a directory is requested.
14
+ * @param {boolean} [options.enableDirectoryListing] Whether to render a simple directory listing when no index file exists.
15
+ * @param {Console} [options.logger] Logger implementation, defaults to console.
16
+ * @returns {http.Server}
17
+ */
18
+ function createStaticServer({
19
+ rootDir,
20
+ indexFiles = ['index.html', 'index.htm'],
21
+ enableDirectoryListing = true,
22
+ logger = console
23
+ }) {
24
+ if (!rootDir) {
25
+ throw new Error('rootDir is required to create a static server');
26
+ }
27
+
28
+ const resolvedRoot = path.resolve(rootDir);
29
+ const rootWithSep = resolvedRoot.endsWith(path.sep)
30
+ ? resolvedRoot
31
+ : `${resolvedRoot}${path.sep}`;
32
+
33
+ const server = http.createServer(async (req, res) => {
34
+ const startTime = Date.now();
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
+ }
47
+
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
+ }
57
+
58
+ const candidateSegments = decodedPath
59
+ .split('/')
60
+ .filter(segment => segment && segment !== '.');
61
+ const resolvedPath = path.resolve(resolvedRoot, ...candidateSegments);
62
+
63
+ if (!resolvedPath.startsWith(rootWithSep) && resolvedPath !== resolvedRoot) {
64
+ sendError(res, 403, 'Forbidden');
65
+ logRequest(logger, req, res.statusCode, startTime);
66
+ return;
67
+ }
68
+
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);
78
+ }
79
+ logRequest(logger, req, res.statusCode, startTime, error);
80
+ return;
81
+ }
82
+
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);
90
+ return;
91
+ }
92
+
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');
99
+ logRequest(logger, req, res.statusCode, startTime);
100
+ return;
101
+ }
102
+ } catch (error) {
103
+ if (error.code !== 'ENOENT') {
104
+ logger.error('Error reading index file', candidate, error);
105
+ }
106
+ }
107
+ }
108
+
109
+ if (!enableDirectoryListing) {
110
+ sendError(res, 403, 'Directory listing disabled');
111
+ logRequest(logger, req, res.statusCode, startTime);
112
+ return;
113
+ }
114
+
115
+ try {
116
+ await serveDirectoryListing(
117
+ res,
118
+ decodedPath,
119
+ resolvedPath,
120
+ req.method === 'HEAD'
121
+ );
122
+ logRequest(logger, req, res.statusCode, startTime);
123
+ 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
+ }
130
+ }
131
+
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
+ }
139
+ logRequest(logger, req, res.statusCode, startTime);
140
+ return;
141
+ }
142
+
143
+ sendError(res, 403, 'Forbidden');
144
+ logRequest(logger, req, res.statusCode, startTime);
145
+ });
146
+
147
+ return server;
148
+ }
149
+
150
+ function sendError(res, statusCode, message) {
151
+ res.statusCode = statusCode;
152
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
153
+ res.setHeader('Cache-Control', 'no-store');
154
+ res.end(`${statusCode} ${message}`);
155
+ }
156
+
157
+ async function serveFile(res, filePath, stats, headOnly) {
158
+ const mimeType = lookup(filePath) || 'application/octet-stream';
159
+ res.statusCode = 200;
160
+ res.setHeader('Content-Type', mimeType);
161
+ res.setHeader('Content-Length', stats.size);
162
+ res.setHeader('Last-Modified', stats.mtime.toUTCString());
163
+ res.setHeader('Cache-Control', 'no-cache');
164
+
165
+ if (headOnly) {
166
+ res.end();
167
+ return;
168
+ }
169
+
170
+ await streamFile(res, filePath);
171
+ }
172
+
173
+ function streamFile(res, filePath) {
174
+ return new Promise((resolve, reject) => {
175
+ const stream = fs.createReadStream(filePath);
176
+ stream.on('error', error => {
177
+ if (!res.headersSent) {
178
+ sendError(res, 500, 'Internal Server Error');
179
+ } else {
180
+ res.destroy(error);
181
+ }
182
+ reject(error);
183
+ });
184
+ stream.on('end', resolve);
185
+ stream.pipe(res);
186
+ });
187
+ }
188
+
189
+ async function serveDirectoryListing(res, requestPath, directoryPath, headOnly) {
190
+ const entries = await readdir(directoryPath, { withFileTypes: true });
191
+ const items = entries
192
+ .map(entry => ({
193
+ name: entry.name,
194
+ isDirectory: entry.isDirectory()
195
+ }))
196
+ .sort((a, b) => {
197
+ if (a.isDirectory && !b.isDirectory) return -1;
198
+ if (!a.isDirectory && b.isDirectory) return 1;
199
+ return a.name.localeCompare(b.name);
200
+ });
201
+
202
+ const detailedItems = [];
203
+ for (const item of items) {
204
+ const fullPath = path.join(directoryPath, item.name);
205
+ try {
206
+ const stats = await stat(fullPath);
207
+ detailedItems.push({
208
+ ...item,
209
+ size: stats.isFile() ? stats.size : null,
210
+ mtime: stats.mtime
211
+ });
212
+ } catch (error) {
213
+ detailedItems.push({
214
+ ...item,
215
+ size: null,
216
+ mtime: null
217
+ });
218
+ }
219
+ }
220
+
221
+ const tableRows = detailedItems
222
+ .map(item => {
223
+ const trailingSlash = item.isDirectory ? '/' : '';
224
+ const href = encodeURIComponent(item.name) + trailingSlash;
225
+ const displayName = `${escapeHtml(item.name)}${trailingSlash}`;
226
+ const size = item.isDirectory ? '-' : formatBytes(item.size);
227
+ const modified = item.mtime ? formatDate(item.mtime) : '-';
228
+ return `<tr><td><a href="${href}">${displayName}</a></td><td>${size}</td><td>${modified}</td></tr>`;
229
+ })
230
+ .join('\n');
231
+
232
+ const body = `<!doctype html>
233
+ <html>
234
+ <head>
235
+ <meta charset="utf-8">
236
+ <title>Index of ${escapeHtml(requestPath)}</title>
237
+ <style>
238
+ body { font-family: sans-serif; padding: 1rem 2rem; }
239
+ h1 { font-size: 1.5rem; margin-bottom: 1rem; }
240
+ table { border-collapse: collapse; width: 100%; max-width: 60rem; }
241
+ th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid #e1e4e8; }
242
+ th { font-weight: 600; background: #f6f8fa; }
243
+ td:nth-child(2), th:nth-child(2) { width: 8rem; }
244
+ td:nth-child(3), th:nth-child(3) { width: 16rem; }
245
+ a { text-decoration: none; color: #0366d6; }
246
+ a:hover { text-decoration: underline; }
247
+ @media (max-width: 600px) {
248
+ table { font-size: 0.9rem; }
249
+ td:nth-child(3), th:nth-child(3) { width: auto; }
250
+ }
251
+ </style>
252
+ </head>
253
+ <body>
254
+ <h1>Index of ${escapeHtml(requestPath)}</h1>
255
+ <table>
256
+ <thead>
257
+ <tr><th>Name</th><th>Size</th><th>Last Modified</th></tr>
258
+ </thead>
259
+ <tbody>
260
+ ${requestPath !== '/' ? '<tr><td><a href="../">../</a></td><td>-</td><td>-</td></tr>' : ''}
261
+ ${tableRows}
262
+ </tbody>
263
+ </table>
264
+ <footer>
265
+ <p>
266
+ Served by <a href="https://github.com/chenpu17/serve-here" target="_blank" rel="noreferrer">serve-here</a>
267
+ </p>
268
+ </footer>
269
+ </body>
270
+ </html>`;
271
+
272
+ res.statusCode = 200;
273
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
274
+ res.setHeader('Cache-Control', 'no-cache');
275
+
276
+ if (headOnly) {
277
+ res.end();
278
+ return;
279
+ }
280
+
281
+ res.end(body);
282
+ }
283
+
284
+ function escapeHtml(value) {
285
+ return value.replace(/[&<>"']/g, character => {
286
+ const replacements = {
287
+ '&': '&amp;',
288
+ '<': '&lt;',
289
+ '>': '&gt;',
290
+ '"': '&quot;',
291
+ "'": '&#39;'
292
+ };
293
+ return replacements[character] || character;
294
+ });
295
+ }
296
+
297
+ function formatBytes(bytes) {
298
+ if (bytes === null || bytes === undefined) {
299
+ return '-';
300
+ }
301
+ if (bytes === 0) {
302
+ return '0 B';
303
+ }
304
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
305
+ const exponent = Math.min(
306
+ Math.floor(Math.log(bytes) / Math.log(1024)),
307
+ units.length - 1
308
+ );
309
+ const size = bytes / 1024 ** exponent;
310
+ return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${units[exponent]}`;
311
+ }
312
+
313
+ function formatDate(date) {
314
+ if (!date) return '-';
315
+ return date.toLocaleString();
316
+ }
317
+
318
+ function logRequest(logger, req, statusCode, startTime, error) {
319
+ const duration = Date.now() - startTime;
320
+ const method = req.method;
321
+ const url = req.url;
322
+ const message = `${method} ${url} ${statusCode} ${duration}ms`;
323
+ if (error) {
324
+ logger.error(message);
325
+ } else {
326
+ logger.info ? logger.info(message) : logger.log(message);
327
+ }
328
+ }
329
+
330
+ module.exports = {
331
+ createStaticServer
332
+ };