dev-server-cli
Version:
Quickly start a local service to implement testing and file transfer
280 lines (261 loc) • 10.9 kB
JavaScript
/**
* 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;