Repository navigation
Expand file tree
/
Copy pathserve.js
More file actions
231 lines (210 loc) · 7.49 KB
/
Copy pathserve.js
File metadata and controls
231 lines (210 loc) · 7.49 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
#!/usr/bin/env node
/*
* 离线数学工具箱 · 局域网静态服务器(零依赖,只需 Node.js)
*
* node serve.js # 默认 8080 端口
* node serve.js 9000 # 指定端口
*
* 启动后:本机访问 http://localhost:8080/
* 同局域网的学生/其他电脑访问 http://<本机IP>:8080/
*/
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { exec } = require('child_process');
const { URL } = require('url');
const ROOT = __dirname;
/* ---------------- 命令行参数 ----------------
* node serve.js 自动选端口(8080 起),并自动打开浏览器
* node serve.js 9000 指定端口
* node serve.js --no-open 不自动打开浏览器(测试用)
*/
const argv = process.argv.slice(2);
const NO_OPEN = argv.includes('--no-open');
const portArg = argv.find((a) => /^\d+$/.test(a)) || process.env.PORT || null;
const START_PORT = portArg ? parseInt(portArg, 10) : 8080;
const MAX_TRIES = portArg ? 0 : 50; // 未指定端口时,被占用就往后找
const MIME = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/plain; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.bmp': 'image/bmp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.eot': 'application/vnd.ms-fontobject',
'.pdf': 'application/pdf',
'.wasm': 'application/wasm',
'.zip': 'application/zip',
'.ggb': 'application/octet-stream',
'.ggt': 'application/octet-stream',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
};
function log(...a) {
console.log('[server]', ...a);
}
function localIPs() {
const out = [];
const ifaces = os.networkInterfaces();
for (const name of Object.keys(ifaces)) {
for (const info of ifaces[name] || []) {
if (info.family === 'IPv4' && !info.internal) out.push({ name, address: info.address });
}
}
return out;
}
const server = http.createServer((req, res) => {
let pathname;
try {
pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
} catch {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
return res.end('400 Bad Request');
}
// 目录穿越防护
const safe = path.normalize(path.join(ROOT, pathname));
if (!safe.startsWith(ROOT)) {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
return res.end('403 Forbidden');
}
fs.stat(safe, (err, st) => {
if (err) return notFound(res, pathname);
if (st.isDirectory()) {
const idx = path.join(safe, 'index.html');
if (fs.existsSync(idx)) return sendFile(req, res, idx, 'text/html; charset=utf-8');
return sendDirListing(res, safe, pathname);
}
sendFile(req, res, safe, MIME[path.extname(safe).toLowerCase()] || 'application/octet-stream');
});
});
function notFound(res, pathname) {
log('404', pathname);
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(
'<meta charset="utf-8"><h2>404 找不到文件</h2>' +
'<p>' + escapeHtml(pathname) + '</p><p><a href="/">← 返回首页</a></p>'
);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
}
function sendDirListing(res, dir, pathname) {
const items = fs.readdirSync(dir, { withFileTypes: true });
const rows = items
.map((d) => {
const href = encodeURI(pathname.replace(/\/?$/, '/') + d.name) + (d.isDirectory() ? '/' : '');
return `<li><a href="${href}">${escapeHtml(d.name)}${d.isDirectory() ? '/' : ''}</a></li>`;
})
.join('');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`<meta charset="utf-8"><h2>目录 ${escapeHtml(pathname)}</h2><ul>${rows}</ul>`);
}
function sendFile(req, res, file, type) {
fs.stat(file, (err, st) => {
if (err || !st.isFile()) return notFound(res, file);
const headers = {
'Content-Type': type,
/* 页面/脚本/样式不缓存:老师更新 U 盘里的文件后立刻生效,
大体积的离线包(GeoGebra / Desmos)才允许缓存一小时 */
'Cache-Control': /\.(html|js|css|json|txt)$/i.test(file)
? 'no-cache'
: 'public, max-age=3600',
'Last-Modified': st.mtime.toUTCString(),
'Accept-Ranges': 'bytes',
};
// 简单的 Range 支持
const range = req.headers.range;
if (range) {
const m = /^bytes=(\d*)-(\d*)$/.exec(range);
if (m) {
let start = m[1] === '' ? st.size - parseInt(m[2], 10) : parseInt(m[1], 10);
let end = m[1] === '' || m[2] === '' ? st.size - 1 : parseInt(m[2], 10);
if (isNaN(start) || isNaN(end) || start < 0 || end >= st.size || start > end) {
res.writeHead(416, { 'Content-Range': `bytes */${st.size}` });
return res.end();
}
headers['Content-Range'] = `bytes ${start}-${end}/${st.size}`;
headers['Content-Length'] = end - start + 1;
res.writeHead(206, headers);
return fs.createReadStream(file, { start, end }).pipe(res);
}
}
headers['Content-Length'] = st.size;
res.writeHead(200, headers);
fs.createReadStream(file).pipe(res);
});
}
/* ---------------- 自动打开浏览器 ---------------- */
function openBrowser(url) {
if (NO_OPEN) {
log('已跳过自动打开浏览器(--no-open):', url);
return;
}
if (process.platform === 'win32') {
exec(`start "" "${url}"`, { shell: 'cmd.exe' }, () => {});
} else if (process.platform === 'darwin') {
exec(`open "${url}"`, () => {});
} else {
exec(`xdg-open "${url}"`, () => {});
}
}
/* ---------------- 启动,端口被占用自动顺延 ---------------- */
let tries = 0;
function listen(port) {
server.listen(port, '0.0.0.0');
}
server.on('error', (e) => {
if (e.code === 'EADDRINUSE' && tries < MAX_TRIES) {
tries += 1;
listen(START_PORT + tries);
return;
}
if (e.code === 'EADDRINUSE') {
console.error(`\n端口 ${START_PORT} 已被占用,请换一个端口: node serve.js 9000\n`);
} else {
console.error(e);
}
process.exit(1);
});
server.on('listening', () => {
const PORT = server.address().port;
const ips = localIPs();
console.log('');
console.log('');
console.log(' ==================================================');
console.log(' 离线数学工具箱 服务已启动');
console.log(' ==================================================');
console.log('');
console.log('');
console.log(` 本机访问 : http://localhost:${PORT}/`);
if (ips.length) {
console.log(' 局域网访问(学生机 / 一体机输入下面地址):');
ips.forEach((i) => console.log(` http://${i.address}:${PORT}/ (${i.name})`));
} else {
console.log(' 未检测到局域网 IP(可能未联网 / 未连 Wi-Fi)');
}
console.log('');
console.log(` 网站根目录 : ${ROOT}`);
console.log(' 停止服务 : 直接关闭本窗口');
console.log('');
openBrowser(`http://127.0.0.1:${PORT}/`);
});
listen(START_PORT);