| const fs = require('node:fs'); |
| const http = require('node:http'); |
| const path = require('node:path'); |
|
|
| const { createLogger, isVerbose } = require('../src/generator/utils/logger'); |
|
|
| const log = createLogger('serve'); |
|
|
| function parseInteger(value, fallback) { |
| const n = Number.parseInt(String(value), 10); |
| return Number.isFinite(n) ? n : fallback; |
| } |
|
|
| function parseArgs(argv) { |
| const args = Array.isArray(argv) ? argv.slice() : []; |
|
|
| const getValue = (keys) => { |
| const idx = args.findIndex((a) => keys.includes(a)); |
| if (idx === -1) return null; |
| const next = args[idx + 1]; |
| return next ? String(next) : null; |
| }; |
|
|
| const portArg = getValue(['--port', '-p']); |
| const hostArg = getValue(['--host', '-h']); |
| const rootArg = getValue(['--root']); |
|
|
| return { |
| port: portArg ? parseInteger(portArg, null) : null, |
| host: hostArg ? String(hostArg) : null, |
| root: rootArg ? String(rootArg) : null, |
| }; |
| } |
|
|
| function getContentType(filePath) { |
| const ext = path.extname(filePath).toLowerCase(); |
| if (ext === '.html') return 'text/html; charset=utf-8'; |
| if (ext === '.js') return 'text/javascript; charset=utf-8'; |
| if (ext === '.css') return 'text/css; charset=utf-8'; |
| if (ext === '.json') return 'application/json; charset=utf-8'; |
| if (ext === '.svg') return 'image/svg+xml'; |
| if (ext === '.png') return 'image/png'; |
| if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; |
| if (ext === '.gif') return 'image/gif'; |
| if (ext === '.webp') return 'image/webp'; |
| if (ext === '.ico') return 'image/x-icon'; |
| if (ext === '.txt') return 'text/plain; charset=utf-8'; |
| if (ext === '.xml') return 'application/xml; charset=utf-8'; |
| if (ext === '.map') return 'application/json; charset=utf-8'; |
| return 'application/octet-stream'; |
| } |
|
|
| function sendNotFound(res, rootDir) { |
| const fallback404 = path.join(rootDir, '404.html'); |
| if (fs.existsSync(fallback404)) { |
| res.statusCode = 404; |
| res.setHeader('Content-Type', 'text/html; charset=utf-8'); |
| fs.createReadStream(fallback404).pipe(res); |
| return; |
| } |
|
|
| res.statusCode = 404; |
| res.setHeader('Content-Type', 'text/plain; charset=utf-8'); |
| res.end('Not Found'); |
| } |
|
|
| function sendFile(req, res, filePath, rootDir) { |
| try { |
| const stat = fs.statSync(filePath); |
| if (!stat.isFile()) return sendNotFound(res, rootDir); |
|
|
| res.statusCode = 200; |
| res.setHeader('Content-Type', getContentType(filePath)); |
| res.setHeader('Content-Length', String(stat.size)); |
|
|
| if (req.method === 'HEAD') { |
| res.end(); |
| return; |
| } |
|
|
| fs.createReadStream(filePath).pipe(res); |
| } catch (error) { |
| log.warn('读取文件失败', { |
| path: filePath, |
| message: error && error.message ? error.message : String(error), |
| }); |
| if (isVerbose() && error && error.stack) console.error(error.stack); |
| sendNotFound(res, rootDir); |
| } |
| } |
|
|
| function buildHandler(rootDir) { |
| const normalizedRoot = path.resolve(rootDir); |
|
|
| return (req, res) => { |
| const rawUrl = req.url || '/'; |
| const rawPath = rawUrl.split('?')[0] || '/'; |
|
|
| let decodedPath = '/'; |
| try { |
| decodedPath = decodeURIComponent(rawPath); |
| } catch { |
| res.statusCode = 400; |
| res.setHeader('Content-Type', 'text/plain; charset=utf-8'); |
| res.end('Bad Request'); |
| return; |
| } |
|
|
| const safePath = decodedPath.replace(/\\/g, '/'); |
| const resolved = path.resolve(normalizedRoot, `.${safePath}`); |
| if (!resolved.startsWith(`${normalizedRoot}${path.sep}`) && resolved !== normalizedRoot) { |
| res.statusCode = 403; |
| res.setHeader('Content-Type', 'text/plain; charset=utf-8'); |
| res.end('Forbidden'); |
| return; |
| } |
|
|
| let target = resolved; |
| try { |
| if (fs.existsSync(target) && fs.statSync(target).isDirectory()) { |
| target = path.join(target, 'index.html'); |
| } |
| } catch { |
| |
| } |
|
|
| if (!fs.existsSync(target)) { |
| sendNotFound(res, normalizedRoot); |
| return; |
| } |
|
|
| sendFile(req, res, target, normalizedRoot); |
| }; |
| } |
|
|
| function startServer(options = {}) { |
| const { rootDir, host, port } = options; |
| const normalizedRoot = path.resolve(rootDir); |
|
|
| if (!fs.existsSync(normalizedRoot)) { |
| throw new Error(`dist 目录不存在:${normalizedRoot}`); |
| } |
|
|
| const handler = buildHandler(normalizedRoot); |
| const server = http.createServer(handler); |
|
|
| return new Promise((resolve, reject) => { |
| server.on('error', reject); |
| server.listen(port, host, () => { |
| const addr = server.address(); |
| const actualPort = addr && typeof addr === 'object' ? addr.port : port; |
| resolve({ server, port: actualPort, host }); |
| }); |
| }); |
| } |
|
|
| async function main() { |
| const repoRoot = path.resolve(__dirname, '..'); |
| const defaultRoot = path.join(repoRoot, 'dist'); |
| const args = parseArgs(process.argv.slice(2)); |
|
|
| const port = |
| args.port ?? parseInteger(process.env.PORT || process.env.MENAV_PORT || '5173', 5173); |
| const host = args.host || process.env.HOST || '0.0.0.0'; |
| const rootDir = args.root ? path.resolve(repoRoot, args.root) : defaultRoot; |
|
|
| log.info('启动静态服务', { root: path.relative(repoRoot, rootDir) || '.', host, port }); |
|
|
| const { server, port: actualPort } = await startServer({ rootDir, host, port }); |
|
|
| log.ok('就绪', { url: `http://localhost:${actualPort}` }); |
|
|
| let shuttingDown = false; |
| const shutdown = (signal) => { |
| if (shuttingDown) return; |
| shuttingDown = true; |
|
|
| process.stdout.write('\n'); |
| log.info('正在关闭...', { signal }); |
|
|
| try { |
| if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections(); |
| if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); |
| } catch { |
| |
| } |
|
|
| const exit = signal === 'SIGINT' ? 130 : 0; |
| const forceTimer = setTimeout(() => process.exit(exit), 2000); |
| if (typeof forceTimer.unref === 'function') forceTimer.unref(); |
|
|
| server.close(() => { |
| clearTimeout(forceTimer); |
| process.exit(exit); |
| }); |
| }; |
|
|
| process.once('SIGINT', () => shutdown('SIGINT')); |
| process.once('SIGTERM', () => shutdown('SIGTERM')); |
| } |
|
|
| if (require.main === module) { |
| main().catch((error) => { |
| log.error('启动失败', { message: error && error.message ? error.message : String(error) }); |
| if (isVerbose() && error && error.stack) console.error(error.stack); |
| process.exitCode = 1; |
| }); |
| } |
|
|
| module.exports = { |
| startServer, |
| }; |
|
|