UNPKG

dev-server-cli

Version:

Quickly start a local service to implement testing and file transfer

280 lines (261 loc) 10.9 kB
/** * server.js * * 使用 Express 框架实现的本地静态文件服务器,支持文件管理、文件上传、API 调试、动态 JS 控制器等功能。 * 支持自定义静态目录和端口,终端输出彩色日志。 * 依赖:express, morgan, cors, multer, kleur, path, fs */ const express = require('express'); const morgan = require('morgan'); const cors = require('cors'); const nocache = require('nocache'); const path = require('path'); const fs = require('fs'); const multer = require('multer'); const kleur = require('kleur'); const cluster = require('cluster'); const os = require('os'); const url = require('url'); const http = require('http'); const https = require('https'); /** * 启动本地静态文件服务器 * @param {string} directory - 静态文件根目录,默认为当前目录 * @param {number} port - 监听端口,默认为 3000 * @param {string} proxyTarget - 代理目标地址,默认为空字符串 * @returns {void} */ function devServer(directory = './', port = 3000, proxyTarget = '') { // 创建 Express 应用 const app = express(); // 日志中间件,格式为 dev app.use(morgan('dev')); // 基础中间件配置 app.use(express.json()); app.use(express.urlencoded({extended: true})); app.use(cors()); app.use(nocache()); /** * 计算静态资源目录的绝对路径 */ const staticDir = path.isAbsolute(directory) ? directory : path.join(process.cwd(), directory); // 动态 JS 控制器:拦截 .js 文件请求,若导出为函数则直接执行 app.use((req, res, next) => { try { const isJsFile = req.path.endsWith('.js'); if (!isJsFile) { // 非 .js 文件直接跳过 next(); return; } const jsFilePath = path.join(staticDir, req.path); // .js 文件不存在则跳过 if (!fs.existsSync(jsFilePath)) { next(); return; } const jsFileObj = require(jsFilePath); // 仅当导出为函数时才作为控制器执行 if (!jsFileObj || typeof jsFileObj !== 'function') { next(); return; } // 执行控制器函数 jsFileObj(req, res, next); } catch (e) { console.error(e); res.status(500).send(`处理 JS 控制器出错: ${e.message}`); } }); // 挂载静态资源服务 app.use(express.static(staticDir)); /** * 根路由:优先返回用户目录下 index.html,找不到则返回内置模板 */ app.get('/', (req, res) => { res.sendFile(path.join(staticDir, 'index.html'), (err) => { if (err) { // 用户目录下无 index.html,返回内置模板 const indexFilePath = path.join(__dirname, '../static/index.html'); fs.readFile(indexFilePath, 'utf8', (e, html) => { if (e) { res.status(202).send(`欢迎使用 dev-server,但未找到 ${indexFilePath}`); } else { res.status(200).send(html); } }); } else { console.log(kleur.green('已从用户目录返回 index.html: ' + staticDir)); } }); }); // 文件管理接口:支持目录遍历与文件上传 app.all('/api/files', (req, res) => { if (req.method === 'GET') { // 遍历 staticDir 目录下的文件和目录,文件夹优先,按名称排序 const currentPath = path.join(staticDir, req.query.dir || ''); fs.readdir(currentPath, (err, files) => { if (err) { res.status(500).send('读取目录失败'); return; } res.json(files.map(file => { const filePath = path.join(currentPath, file); const stats = fs.statSync(filePath); return { name: file, isDir: stats.isDirectory(), size: stats.size, modified: stats.mtime, downloadUrl: filePath.replace(staticDir, '/'), }; }).sort((a, b) => { if (a.isDir && !b.isDir) { return -1; } if (!a.isDir && b.isDir) { return 1; } return a.name.localeCompare(b.name); })); }) } else if (req.method === 'POST') { // 文件上传,保留原始文件名 const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, path.join(staticDir, req.query.dir || '')); }, filename: function (req, file, cb) { cb(null, file.originalname); } }); const upload = multer({storage}); upload.single('file')(req, res, function (err) { if (err) { return res.status(500).json({error: '文件上传失败', detail: err.message}); } res.json({status: 'success', filename: req.file.originalname, path: req.file.path}); }); } else { res.status(405).send('不支持的请求方法'); } }); // API 调试接口:回显所有请求信息,便于联调 app.all('/api/debug', (req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk.toString(); }); const result = { url: req.url, method: req.method, headers: req.headers ?? {}, body: body ?? {}, query: req.query ?? {}, params: req.params ?? {}, ip: req.ip || req.connection.remoteAddress || req.socket.remoteAddress || 'Unknown_IP', timestamp: new Date().toISOString(), path: path.join(staticDir, req.url), }; req.on('end', () => { setImmediate(() => { console.log(kleur.gray(`${result.method} ${result.url} ${kleur.magenta(process.pid)} Request:\n`) + kleur.green(JSON.stringify(result, null, 2))); }); res.status(200).json(result); }); }); // API 请求代理:将 /api/* 的请求转发到指定 API 服务器(原生实现) const {apiPrefix, proxyUrl} = getProxyTarget(proxyTarget); if (apiPrefix && proxyUrl) { const targetUrl = url.parse(proxyUrl); const isHttps = targetUrl.protocol === 'https:'; const agent = isHttps ? https : http; app.use(`${apiPrefix}/*path`, (req, res) => { // 构造目标路径 const targetPath = req.originalUrl.replace(apiPrefix, ''); const options = { protocol: targetUrl.protocol, hostname: targetUrl.hostname, port: targetUrl.port || (isHttps ? 443 : 80), method: req.method, path: targetPath + (req.url.includes('?') ? '' : req.url.split('?')[1] ? '?' + req.url.split('?')[1] : ''), headers: Object.assign({}, req.headers, {host: targetUrl.host}), timeout: 30000, }; // 处理 body let bodyData = []; req.on('data', chunk => bodyData.push(chunk)); req.on('end', () => { const proxyReq = agent.request(options, proxyRes => { res.status(proxyRes.statusCode); Object.entries(proxyRes.headers).forEach(([key, value]) => { if (typeof value !== 'undefined') res.setHeader(key, value); }); proxyRes.pipe(res); }); proxyReq.on('error', err => { res.status(502).json({error: '代理请求失败', detail: err.message}); }); proxyReq.on('timeout', () => { proxyReq.destroy(); res.status(504).json({error: '代理超时'}); }); if (bodyData.length > 0) { proxyReq.write(Buffer.concat(bodyData)); } proxyReq.end(); }); }); } // 启动服务并输出启动信息 app.listen(port, () => { console.log( kleur.green().bold(`服务已启动 (${kleur.magenta(`pid=${process.pid}`)}): ${kleur.blue(`http://localhost:${port}/`)}`) + '\n' + kleur.gray(' - ') + kleur.red(`执行目录: ${__dirname}`) + '\n' + kleur.gray(' - ') + kleur.yellow(`文件目录: ${staticDir}`) + '\n' + kleur.gray(' - ') + kleur.cyan(`调试接口: http://localhost:${port}/api/debug/`) + '\n' + kleur.gray(' - ') + kleur.green(`代理路径: ${apiPrefix ? apiPrefix + '/*' : 'undefined'} -> ${proxyUrl || 'undefined'}`) + '\n' + kleur.gray(' - ') + kleur.magenta(`停止服务: 按 Ctrl+C / Cmd+C`) + '\n' ); }); } /** * 解析代理目标 * @param value - 代理目标字符串,格式为 "apiPrefix:proxyUrl" * @returns {{apiPrefix: *, proxyUrl: *}} */ function getProxyTarget(value) { if (value && value.length > 0 && value.indexOf('@') > 0) { const apiPrefix = value.split('@')[0]; const proxyUrl = value.split('@')[1]; return {apiPrefix, proxyUrl}; } else { return {apiPrefix: null, proxyUrl: null}; } } /** * 启动服务 * @param {string} directory - 静态文件根目录,默认为当前目录 * @param {number} port - 监听端口,默认为 3000 * @param {string} proxyTarget - 代理目标地址,默认为空字符串 * @param {number} processNum - 进程数量,默认为 1 * @returns {void} */ function starter(directory = './', port = 3000, proxyTarget = '', processNum = 1) { if (processNum > 1 && cluster.isMaster) { let core = Math.min(processNum, os.cpus().length); // 进程数量不能超过CPU核数 console.log(kleur.green().bold(`启动 ${core} 个工作进程...\n`)); for (let i = 0; i < core; i++) { cluster.fork(); } cluster.on('exit', (worker, code, signal) => { console.log(kleur.green(`工作进程 ${kleur.red(worker.process.pid)} 退出,正在重启...`)); cluster.fork(); }); } else if (cluster.isWorker || processNum <= 1) { devServer(directory, port, proxyTarget); } else { console.log(kleur.red('请使用正确的启动参数')); } } module.exports = starter;