325 lines
10 KiB
JavaScript
325 lines
10 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 自定义静态文件服务器,支持自定义404页面和Next.js静态导出
|
|||
|
|
* 用于部署Next.js静态导出的文件
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const http = require('http');
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
const url = require('url');
|
|||
|
|
|
|||
|
|
class StaticServer {
|
|||
|
|
constructor(directory = 'out', port = 8080) {
|
|||
|
|
this.directory = path.resolve(directory);
|
|||
|
|
this.port = port;
|
|||
|
|
|
|||
|
|
// MIME类型映射
|
|||
|
|
this.mimeTypes = {
|
|||
|
|
'.html': 'text/html; charset=utf-8',
|
|||
|
|
'.js': 'application/javascript; charset=utf-8',
|
|||
|
|
'.css': 'text/css; charset=utf-8',
|
|||
|
|
'.json': 'application/json; charset=utf-8',
|
|||
|
|
'.png': 'image/png',
|
|||
|
|
'.jpg': 'image/jpeg',
|
|||
|
|
'.jpeg': 'image/jpeg',
|
|||
|
|
'.gif': 'image/gif',
|
|||
|
|
'.svg': 'image/svg+xml',
|
|||
|
|
'.ico': 'image/x-icon',
|
|||
|
|
'.webp': 'image/webp',
|
|||
|
|
'.woff': 'font/woff',
|
|||
|
|
'.woff2': 'font/woff2',
|
|||
|
|
'.ttf': 'font/ttf',
|
|||
|
|
'.eot': 'application/vnd.ms-fontobject'
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
getMimeType(filePath) {
|
|||
|
|
const ext = path.extname(filePath).toLowerCase();
|
|||
|
|
return this.mimeTypes[ext] || 'application/octet-stream';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ensureErrorPages() {
|
|||
|
|
// 确保404.html存在于out目录
|
|||
|
|
const out404Path = path.join(this.directory, '404.html');
|
|||
|
|
const public404Path = path.join(__dirname, '..', 'public', '404.html');
|
|||
|
|
|
|||
|
|
if (!fs.existsSync(out404Path) && fs.existsSync(public404Path)) {
|
|||
|
|
try {
|
|||
|
|
fs.copyFileSync(public404Path, out404Path);
|
|||
|
|
console.log('✅ 已复制404.html到out目录');
|
|||
|
|
} catch (error) {
|
|||
|
|
console.log('⚠️ 无法复制404.html:', error.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sendFile(res, filePath, statusCode = 200) {
|
|||
|
|
try {
|
|||
|
|
if (!fs.existsSync(filePath)) {
|
|||
|
|
this.send404(res);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stat = fs.statSync(filePath);
|
|||
|
|
const content = fs.readFileSync(filePath);
|
|||
|
|
const mimeType = this.getMimeType(filePath);
|
|||
|
|
|
|||
|
|
res.writeHead(statusCode, {
|
|||
|
|
'Content-Type': mimeType,
|
|||
|
|
'Content-Length': stat.size,
|
|||
|
|
'Cache-Control': filePath.endsWith('.html')
|
|||
|
|
? 'no-cache, no-store, must-revalidate'
|
|||
|
|
: 'public, max-age=31536000',
|
|||
|
|
'X-Content-Type-Options': 'nosniff',
|
|||
|
|
'X-Frame-Options': 'DENY',
|
|||
|
|
'X-XSS-Protection': '1; mode=block'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.end(content);
|
|||
|
|
console.log(`✓ ${statusCode} ${filePath}`);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(`✗ 错误读取文件 ${filePath}:`, error.message);
|
|||
|
|
this.send500(res, error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
send404(res) {
|
|||
|
|
// 尝试找到404页面
|
|||
|
|
const custom404Paths = [
|
|||
|
|
path.join(this.directory, '404.html'),
|
|||
|
|
path.join(this.directory, '404', 'index.html'),
|
|||
|
|
path.join(__dirname, '..', 'public', '404.html')
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
for (const custom404Path of custom404Paths) {
|
|||
|
|
if (fs.existsSync(custom404Path)) {
|
|||
|
|
console.log(`✓ 返回自定义404页面: ${custom404Path}`);
|
|||
|
|
this.sendFile(res, custom404Path, 404);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果没有找到自定义404页面,返回简单的404
|
|||
|
|
const content = `
|
|||
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|||
|
|
<title>404 - 页面未找到 - AwsLinker</title>
|
|||
|
|
<style>
|
|||
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|||
|
|
body {
|
|||
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|||
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|||
|
|
min-height: 100vh;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
color: #333;
|
|||
|
|
}
|
|||
|
|
.container {
|
|||
|
|
max-width: 500px;
|
|||
|
|
width: 90%;
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
|||
|
|
padding: 3rem 2rem;
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
.error-code {
|
|||
|
|
font-size: 4rem;
|
|||
|
|
font-weight: 800;
|
|||
|
|
color: #667eea;
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
}
|
|||
|
|
.error-title {
|
|||
|
|
font-size: 1.5rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #2d3748;
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
}
|
|||
|
|
.error-description {
|
|||
|
|
color: #718096;
|
|||
|
|
margin-bottom: 2rem;
|
|||
|
|
line-height: 1.6;
|
|||
|
|
}
|
|||
|
|
.btn {
|
|||
|
|
display: inline-block;
|
|||
|
|
padding: 0.75rem 1.5rem;
|
|||
|
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
|||
|
|
color: white;
|
|||
|
|
text-decoration: none;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
transition: transform 0.2s;
|
|||
|
|
}
|
|||
|
|
.btn:hover {
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="container">
|
|||
|
|
<div class="error-code">404</div>
|
|||
|
|
<h1 class="error-title">页面未找到</h1>
|
|||
|
|
<p class="error-description">抱歉,您访问的页面不存在或已被移除。</p>
|
|||
|
|
<a href="/" class="btn">返回首页</a>
|
|||
|
|
</div>
|
|||
|
|
</body>
|
|||
|
|
</html>`;
|
|||
|
|
|
|||
|
|
res.writeHead(404, {
|
|||
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|||
|
|
'Content-Length': Buffer.byteLength(content, 'utf8')
|
|||
|
|
});
|
|||
|
|
res.end(content);
|
|||
|
|
console.log(`✗ 404 返回默认404页面`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
send500(res, error) {
|
|||
|
|
const content = `
|
|||
|
|
<!DOCTYPE html>
|
|||
|
|
<html>
|
|||
|
|
<head>
|
|||
|
|
<title>500 - 服务器错误</title>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<h1>500 - 服务器内部错误</h1>
|
|||
|
|
<p>服务器处理请求时发生错误。</p>
|
|||
|
|
<p><a href="/">返回首页</a></p>
|
|||
|
|
</body>
|
|||
|
|
</html>`;
|
|||
|
|
|
|||
|
|
res.writeHead(500, {
|
|||
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|||
|
|
'Content-Length': Buffer.byteLength(content, 'utf8')
|
|||
|
|
});
|
|||
|
|
res.end(content);
|
|||
|
|
console.log(`✗ 500 服务器错误:`, error.message);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
handleRequest(req, res) {
|
|||
|
|
const parsedUrl = url.parse(req.url, true);
|
|||
|
|
let pathname = decodeURIComponent(parsedUrl.pathname);
|
|||
|
|
|
|||
|
|
// 移除查询参数和fragment
|
|||
|
|
pathname = pathname.split('?')[0].split('#')[0];
|
|||
|
|
|
|||
|
|
console.log(`→ ${req.method} ${pathname}`);
|
|||
|
|
|
|||
|
|
// 安全检查:防止目录遍历攻击
|
|||
|
|
if (pathname.includes('..') || pathname.includes('\0')) {
|
|||
|
|
this.send404(res);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理根路径
|
|||
|
|
if (pathname === '/') {
|
|||
|
|
const indexPath = path.join(this.directory, 'index.html');
|
|||
|
|
this.sendFile(res, indexPath);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 构建文件路径
|
|||
|
|
let filePath = path.join(this.directory, pathname.slice(1));
|
|||
|
|
|
|||
|
|
// 检查文件是否存在
|
|||
|
|
if (fs.existsSync(filePath)) {
|
|||
|
|
const stat = fs.statSync(filePath);
|
|||
|
|
|
|||
|
|
if (stat.isDirectory()) {
|
|||
|
|
// 如果是目录,尝试找index.html
|
|||
|
|
const indexPath = path.join(filePath, 'index.html');
|
|||
|
|
if (fs.existsSync(indexPath)) {
|
|||
|
|
this.sendFile(res, indexPath);
|
|||
|
|
} else {
|
|||
|
|
this.send404(res);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 文件存在,直接返回
|
|||
|
|
this.sendFile(res, filePath);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 文件不存在,尝试添加.html扩展名
|
|||
|
|
const htmlPath = filePath + '.html';
|
|||
|
|
if (fs.existsSync(htmlPath)) {
|
|||
|
|
this.sendFile(res, htmlPath);
|
|||
|
|
} else {
|
|||
|
|
// 尝试在index.html中查找(适用于Next.js路由)
|
|||
|
|
const indexPath = path.join(filePath, 'index.html');
|
|||
|
|
if (fs.existsSync(indexPath)) {
|
|||
|
|
this.sendFile(res, indexPath);
|
|||
|
|
} else {
|
|||
|
|
this.send404(res);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
start() {
|
|||
|
|
// 检查输出目录是否存在
|
|||
|
|
if (!fs.existsSync(this.directory)) {
|
|||
|
|
console.error(`❌ 错误: 目录 '${this.directory}' 不存在`);
|
|||
|
|
console.error('请先运行构建命令生成静态文件:npm run build:static');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 确保404.html存在于out目录
|
|||
|
|
this.ensureErrorPages();
|
|||
|
|
|
|||
|
|
const server = http.createServer((req, res) => {
|
|||
|
|
this.handleRequest(req, res);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
server.listen(this.port, () => {
|
|||
|
|
console.log('🚀 静态文件服务器已启动');
|
|||
|
|
console.log(`📁 服务目录: ${this.directory}`);
|
|||
|
|
console.log(`🌐 访问地址: http://localhost:${this.port}`);
|
|||
|
|
console.log('⌨️ 按 Ctrl+C 停止服务器');
|
|||
|
|
console.log('━'.repeat(50));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 优雅关闭
|
|||
|
|
process.on('SIGINT', () => {
|
|||
|
|
console.log('\n🛑 正在关闭服务器...');
|
|||
|
|
server.close(() => {
|
|||
|
|
console.log('✅ 服务器已停止');
|
|||
|
|
process.exit(0);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
process.on('SIGTERM', () => {
|
|||
|
|
console.log('\n🛑 收到终止信号,正在关闭服务器...');
|
|||
|
|
server.close(() => {
|
|||
|
|
console.log('✅ 服务器已停止');
|
|||
|
|
process.exit(0);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 主函数
|
|||
|
|
function main() {
|
|||
|
|
const args = process.argv.slice(2);
|
|||
|
|
const port = args[0] ? parseInt(args[0]) : 8080;
|
|||
|
|
const directory = args[1] || 'out';
|
|||
|
|
|
|||
|
|
if (isNaN(port) || port < 1 || port > 65535) {
|
|||
|
|
console.error('❌ 错误: 无效的端口号');
|
|||
|
|
console.log('使用方法: node serve-static.js [端口号] [目录]');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const server = new StaticServer(directory, port);
|
|||
|
|
server.start();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (require.main === module) {
|
|||
|
|
main();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = StaticServer;
|