221 lines
10 KiB
JavaScript
221 lines
10 KiB
JavaScript
/* Minimal static dev server for the component showcase site.
|
||
Usage (from repo root): node site/dev-server.js
|
||
Then open: http://127.0.0.1:3311/site/
|
||
Serves the repo root so /site, /frameworks and /.design_library are all reachable. */
|
||
'use strict';
|
||
const http = require('http');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
/* 默认端口 3311;KOLE_PORT 仅供验证脚本起独立实例(避免与正在运行的 3311 抢端口)。
|
||
非法值(非整数 / 越界 / 空串)一律回落到 3311,保持既有行为。 */
|
||
const PORT = (() => {
|
||
const raw = process.env.KOLE_PORT;
|
||
if (raw === undefined || raw === '') return 3311;
|
||
const n = Number(raw);
|
||
return Number.isInteger(n) && n > 0 && n < 65536 ? n : 3311;
|
||
})();
|
||
const HOST = '127.0.0.1';
|
||
const ROOT = path.resolve(process.cwd());
|
||
|
||
const TYPES = {
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.mjs': 'text/javascript; charset=utf-8',
|
||
'.json': 'application/json; charset=utf-8',
|
||
'.txt': 'text/plain; charset=utf-8',
|
||
'.vue': 'text/plain; charset=utf-8',
|
||
'.svg': 'image/svg+xml',
|
||
'.png': 'image/png',
|
||
'.ico': 'image/x-icon'
|
||
};
|
||
|
||
const SECURITY_HEADERS = {
|
||
'Content-Security-Policy': [
|
||
"default-src 'self'",
|
||
"script-src 'self' 'unsafe-inline'",
|
||
"style-src 'self' 'unsafe-inline'",
|
||
"img-src 'self' data:",
|
||
"font-src 'self' data:",
|
||
"connect-src 'self'",
|
||
"frame-src 'self'",
|
||
"object-src 'none'",
|
||
"base-uri 'self'",
|
||
"frame-ancestors 'self'",
|
||
"form-action 'self'"
|
||
].join('; '),
|
||
'X-Content-Type-Options': 'nosniff',
|
||
'Referrer-Policy': 'no-referrer',
|
||
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
||
};
|
||
|
||
const SENSITIVE_SEGMENT = /^(?:\.git|\.env(?:\..*)?|backup(?:s)?|log(?:s)?)$/i;
|
||
const SENSITIVE_FILE = /\.(?:bak|backup|log)$/i;
|
||
|
||
function isSensitivePath(relativePath) {
|
||
if (!relativePath || relativePath === '..' || path.isAbsolute(relativePath)) return true;
|
||
const segments = relativePath.split(path.sep);
|
||
return segments.some((segment) => SENSITIVE_SEGMENT.test(segment) || SENSITIVE_FILE.test(segment));
|
||
}
|
||
|
||
const PUBLIC_ROOTS = new Set([
|
||
'site',
|
||
'frameworks',
|
||
/* 逐端预渲染产物(ROADMAP S9-P2:site/end-preview/*.html)。
|
||
新增顶层目录必须同时加进这里与 isPublicPath(),否则 403(AGENTS 陷阱速查同款)。 */
|
||
'end-preview',
|
||
/* 移动端平台实现目录 + PC×uni-app 端实现目录(与 PC 侧 frameworks/ 物理隔离,见 PLATFORMS.md) */
|
||
'frameworks-mobile',
|
||
'frameworks-uniapp-pc',
|
||
'.design_library',
|
||
'tests',
|
||
'index.html',
|
||
'sitemap.xml',
|
||
/* 版本清单:站点根在部署前缀下时 URL 是 <prefix>/versions.json,仓库里对应根目录这一份 */
|
||
'versions.json',
|
||
]);
|
||
const PUBLIC_SPEC_FILES = new Set(Array.from({ length: 10 }, (_, i) => `组件${i + 1}.txt`));
|
||
/* 历史版本文档站快照(tools/snapshot-site.mjs 产出):仓库根的 <x.y.z>/ 目录,
|
||
内部结构是 site/ + frameworks/ + .design_library/,URL 形如 /1.4.1/site/… */
|
||
const VERSION_DIR = /^\d+\.\d+\.\d+$/;
|
||
|
||
function isPublicPath(relativePath) {
|
||
if (!relativePath || relativePath === '.' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) return false;
|
||
const segments = relativePath.split(path.sep);
|
||
const top = segments[0];
|
||
if (top === 'index.html' || top === 'sitemap.xml' || top === 'versions.json') return segments.length === 1;
|
||
if (top === 'site' || top === 'frameworks' || top === 'tests') return true;
|
||
if (top === 'frameworks-mobile' || top === 'frameworks-uniapp-pc') return true;
|
||
if (top === '.design_library') return true;
|
||
if (VERSION_DIR.test(top)) return true;
|
||
// 兼容旧版文档站:仅允许仓库根的 10 份规范原文,其余根文件一律拒绝
|
||
return segments.length === 1 && PUBLIC_SPEC_FILES.has(top);
|
||
}
|
||
|
||
function writeResponse(res, statusCode, headers, body) {
|
||
res.writeHead(statusCode, { ...SECURITY_HEADERS, ...headers });
|
||
res.end(body);
|
||
}
|
||
|
||
const server = http.createServer((req, res) => {
|
||
const rawPath = req.url.split('?')[0];
|
||
// 原始路径含编码形态的 「.」「/」或 NUL 一律拒绝:
|
||
// - %2e/%2f(含大小写变体与双重编码)解码后会把 /%2e%2e/x 归一化成合法根路径
|
||
// - %00 / %2500 解码后是 NUL,会让 fs.readFile 同步抛 TypeError 打挂进程
|
||
if (/%(?:2e|2f|252e|252f|00|2500)/i.test(rawPath)) {
|
||
writeResponse(res, 403, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Forbidden');
|
||
return;
|
||
}
|
||
let urlPath;
|
||
try {
|
||
urlPath = decodeURIComponent(rawPath).replace(/\\/g, '/');
|
||
} catch (e) {
|
||
writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request');
|
||
return;
|
||
}
|
||
// 解码后再校验一次 NUL(双保险):无论编码形态如何(含裸 NUL、平台解码差异),
|
||
// 都不得把 NUL 带进后面的文件路径
|
||
if (urlPath.indexOf('\u0000') !== -1) {
|
||
writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request');
|
||
return;
|
||
}
|
||
if (urlPath === '/' || urlPath === '') {
|
||
writeResponse(res, 302, { Location: '/site/' });
|
||
return;
|
||
}
|
||
// 版本清单只留部署根一份(仓库根 versions.json):快照自带的那份是归档当时的快照,
|
||
// 之后新归档的版本不会进去,会让快照页误判成单版本(实测:切到旧版后回不到最新版)。
|
||
// 因此 /<x.y.z>/versions.json 也指向根那份,而不是回落到该版壳。
|
||
if (urlPath === '/versions.json' || /^\/\d+\.\d+\.\d+\/versions\.json$/.test(urlPath)) {
|
||
urlPath = '/versions.json';
|
||
} else if (urlPath.endsWith('/')) {
|
||
urlPath += 'index.html';
|
||
}
|
||
// 根路径与常见入口自动跳转到站点,避免裸访问 404
|
||
if (urlPath === '/index.html') {
|
||
writeResponse(res, 302, { Location: '/site/' });
|
||
return;
|
||
}
|
||
// 历史版本快照的裸入口:/<x.y.z>/ 与 /<x.y.z>/index.html 都送到该版的站点壳
|
||
const verRoot = urlPath.match(/^\/(\d+\.\d+\.\d+)\/(?:index\.html)?$/);
|
||
if (verRoot) {
|
||
writeResponse(res, 302, { Location: `/${verRoot[1]}/site/` });
|
||
return;
|
||
}
|
||
const filePath = path.resolve(ROOT, `.${urlPath}`);
|
||
const relativePath = path.relative(ROOT, filePath);
|
||
// 开发服务只开放站点运行所需的顶层目录;仓库元数据、配置、脚本一律拒绝
|
||
if (!isPublicPath(relativePath) || isSensitivePath(relativePath)) {
|
||
writeResponse(res, 403, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Forbidden');
|
||
return;
|
||
}
|
||
// 文档站用 History API 路由(URL 里没有 #):/site/component/button/h5 这类深层路径
|
||
// 磁盘上没有对应文件,回落到 SPA 壳 site/index.html,由 app.js 按 pathname 渲染。
|
||
// 与 nginx.conf 的 location /site/ 行为一致;只对无扩展名的路径生效 —— 带扩展名的
|
||
// 缺失资源按真 404,避免把一份 HTML 当成 JS/CSS 发出去。
|
||
// 历史版本快照(/<x.y.z>/site/…)走同一条规则,回落到它自己那份壳。
|
||
const spaMatch = urlPath.match(/^((?:\/\d+\.\d+\.\d+)?\/site\/)/);
|
||
const isSpaRoute = !!spaMatch
|
||
&& !/\.[a-z0-9]+$/i.test(urlPath)
|
||
&& !fs.existsSync(filePath);
|
||
const servePath = isSpaRoute ? path.join(ROOT, spaMatch[1], 'index.html') : filePath;
|
||
// fs.readFile 会对含 NUL 的路径**同步**抛 ERR_INVALID_ARG_VALUE;异常若逃逸出请求
|
||
// 回调会打挂进程(单个畸形请求即可 DoS)。这里兜住同步抛错,转成 400 响应。
|
||
try {
|
||
fs.readFile(servePath, (err, buf) => {
|
||
if (err) {
|
||
writeResponse(res, 404, { 'Content-Type': 'text/plain; charset=utf-8' }, '404 Not Found: ' + urlPath);
|
||
return;
|
||
}
|
||
writeResponse(res, 200, {
|
||
'Content-Type': TYPES[path.extname(servePath).toLowerCase()] || 'application/octet-stream',
|
||
/* 版本清单每次构建都会重写,缓存它会让菜单停在旧版本列表上 */
|
||
'Cache-Control': path.basename(servePath) === 'versions.json' ? 'no-store' : 'no-cache'
|
||
}, buf);
|
||
});
|
||
} catch (e) {
|
||
writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request');
|
||
}
|
||
});
|
||
|
||
/* 启动:默认 3311;被占用时自动向上找下一个空闲端口(最多 10 档),
|
||
而不是直接 EADDRINUSE 退出 —— 实测踩过:上一个改动前启动的实例还占着 3311,
|
||
新实例静默没起来,浏览器里看到的是旧白名单(新目录 403),极易误判成"构建没产出"。
|
||
KOLE_PORT 显式指定时不回退(脚本/CI 依赖固定端口,拿不到就该报错退出)。 */
|
||
let listenAttempts = 0;
|
||
|
||
/* 只注册一次,端口取 server.address() —— 回退后闭包里的旧端口值是错的(实测先打印了 3311)。 */
|
||
server.on('listening', () => {
|
||
const base = `http://${HOST}:${server.address().port}`;
|
||
console.log(`Kole UI showcase serving at ${base}/site/`);
|
||
console.log('');
|
||
console.log(' 访问入口:');
|
||
console.log(` PC 文档站 ${base}/site/`);
|
||
console.log(` 移动端文档站 ${base}/site/m/`);
|
||
console.log(` PC 组件总览 ${base}/site/overview`);
|
||
console.log(` PC 测试总览 ${base}/tests/index.html`);
|
||
console.log(` 移动端测试总览 ${base}/tests/mobile/index.html`);
|
||
console.log(` 回归收集器(PC / 移动端) ${base}/tests/_collect.html · ${base}/tests/mobile/_collect.html`);
|
||
console.log('');
|
||
console.log(' 提示:需要固定端口时用 KOLE_PORT=13511 node site/dev-server.js;');
|
||
console.log(` 回归与门禁脚本用 REG_BASE=${base} 指向本实例。`);
|
||
});
|
||
|
||
function listen(port) {
|
||
server.removeAllListeners('error');
|
||
server.on('error', (e) => {
|
||
const explicit = process.env.KOLE_PORT !== undefined && process.env.KOLE_PORT !== '';
|
||
if (e.code === 'EADDRINUSE' && !explicit && listenAttempts < 10) {
|
||
listenAttempts++;
|
||
console.warn(`端口 ${port} 已被占用,改用 ${port + 1} 启动(KOLE_PORT 可固定端口)`);
|
||
listen(port + 1);
|
||
return;
|
||
}
|
||
console.error('Server error:', e.message);
|
||
process.exit(1);
|
||
});
|
||
server.listen(port, HOST);
|
||
}
|
||
listen(PORT);
|