Files
cms-website/apps/preview-server/server.mjs
T
浅念 7194d1bcef feat(preview): 路由驱动的本地预览服务器
Plan 4 Task 7(V2 spec §12.2):apps/preview-server 零依赖 node:http 服务——
只读 succeeded job 的 dist + route-manifest(不查库),exact/ignore 双索引匹配
(query_key 行全串优先),html/asset 文件服务(manifest content_type 优先、
扩展名 MIME 表兜底、未知扩展 octet-stream)、redirect 301+Location、gone 410、
未命中 404 走 dist/404.html 自定义页;全响应 X-Robots-Tag noindex + HTML 响应
基础 CSP;回环绑定白名单(库级即拒);穿越预检先于 URL 解析(裸/编码点段、
反斜杠)+ target_file resolve+realpath 双检在 dist 内;目录请求一律 404(禁
列表);仅 GET/HEAD。sf.ts 增 preview:start(job 定位 + succeeded 校验 + 产物
行读取后即断池;--host 外值拒绝先于 DB 访问)。集成测试 12 例:真实构建产物
全链 + 手构 gone/MIME 探针行 + CLI 拒绝与子进程 happy path 冒烟。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 13:29:30 +08:00

318 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 本地预览服务器(Plan 4 Task 7,V2 spec §12.2):路由驱动的静态 dist 服务——
// 只读 <artifactsRoot>/jobs/<jobId>/dist + route-manifest.json 两个输入,不查库、
// 不写盘、零新依赖(node:http)。行为面:
// - 匹配器:URL 解析 pathname + search;query_policy 处理——exact 行(query_key 非空)
// 以 `pathname+search` 全串匹配且优先于 ignore 行;ignore 行剥 query 按 path 匹配。
// html/asset → target_file 文件服务(manifest content_type 优先,缺席走扩展名 MIME
// 表,未知扩展 application/octet-stream);redirect → status+Location;gone → 410;
// dynamic/unresolved → 404(静态产物无对应文件);无匹配 → 404(dist/404.html 自定义页)。
// - 安全:回环绑定(host 仅 127.0.0.1/localhost,库级即拒);路径穿越预拒(反斜杠/
// 编码形态/.. 段)+ target_file resolve 后必须在 dist 内(lexical + realpath 双检);
// 目录即 404(禁目录列表);仅 GET/HEAD(其余 405)。
// - 头纪律:全响应 X-Robots-Tag: noindex, nofollow;text/html 响应附基础 CSP
// (default-src 'self')。请求日志一行(method/path/status/耗时)到 stdout。
//
// 本文件刻意保持 .mjs 纯 JavaScript(不进 TS 链);TS 消费方(scripts/sf.ts 与集成
// 测试)的类型面由相邻 server.d.mts 声明供给(NodeNext 解析自动拾取)。
import { createServer } from 'node:http'
import { readFile, realpath, stat } from 'node:fs/promises'
import { extname, join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
/** 扩展名 → Content-Type(manifest content_type 缺席时的兜底映射;未列出 → octet-stream) */
const MIME_BY_EXT = new Map([
['.html', 'text/html'],
['.htm', 'text/html'],
['.css', 'text/css'],
['.js', 'text/javascript'],
['.mjs', 'text/javascript'],
['.json', 'application/json'],
['.xml', 'application/xml'],
['.txt', 'text/plain'],
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.gif', 'image/gif'],
['.webp', 'image/webp'],
['.svg', 'image/svg+xml'],
['.ico', 'image/x-icon'],
['.pdf', 'application/pdf'],
['.woff', 'font/woff'],
['.woff2', 'font/woff2'],
])
/** 回环绑定白名单(§12.2:预览永不对外)——外值在服务构造点即拒(先于监听) */
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost'])
const X_ROBOTS_TAG = 'noindex, nofollow'
const CSP = "default-src 'self'"
/**
* 请求路径安全预检(**先于 URL 解析**——new URL 会把 '/../' 规范化到 '/',裸点段
* 必须在规范化前拦截):反斜杠(裸/编码)、NUL、解码失败的百分号串、解码后的
* '..' 段一律拒绝。解码同时把 %2f/%5c 还原为分隔符再切分——'..%5c'、'%2e%2e%2f'
* 等编码形态与裸 '..' 同罪。解码只用于**检测**:路由匹配始终用原样(未解码)
* pathname,与 route-manifest exact_key 的编码形式(如 %e5%85%b3…)逐字节对齐。
*/
function isUnsafeRequestPath(rawUrl) {
const pathPart = rawUrl.split('#')[0].split('?')[0]
if (pathPart.includes('\\')) return true
if (pathPart.includes('\0')) return true
let decoded
try {
decoded = decodeURIComponent(pathPart)
} catch {
return true // 孤立 % 等非法编码——拒绝(不猜意图)
}
if (decoded.includes('\\') || decoded.includes('\0')) return true
return decoded.split(/[\\/]/).includes('..')
}
function contentTypeForExt(file) {
return MIME_BY_EXT.get(extname(file).toLowerCase()) ?? 'application/octet-stream'
}
/** 统一响应出口:全响应 noindex 头;text/html 附 CSP;HEAD 不写 body(Content-Length 仍按实体设置) */
function send(res, status, contentType, body, isHead, extraHeaders = undefined) {
res.statusCode = status
res.setHeader('X-Robots-Tag', X_ROBOTS_TAG)
if (contentType.startsWith('text/html')) res.setHeader('Content-Security-Policy', CSP)
if (extraHeaders !== undefined) {
for (const [name, value] of Object.entries(extraHeaders)) res.setHeader(name, value)
}
res.setHeader('Content-Type', contentType)
res.setHeader('Content-Length', String(Buffer.byteLength(body)))
res.end(isHead ? undefined : body)
}
function htmlDocument(title, bodyText) {
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"/><title>${title}</title></head><body><h1>${title}</h1><p>${bodyText}</p></body></html>\n`
}
const GONE_HTML = htmlDocument('410 Gone', '此内容已被永久移除(route-manifest kind=gone)。')
/**
* 创建预览服务器(不监听——start() 才绑定)。返回 {server, start, stop}:
* start() → Promise<AddressInfo>(port=0 时返回实际端口);stop() 关连接再关实例
* (closeAllConnections 防 keep-alive 空闲套接字吊住 close)。
*/
export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1', port = 4173 }) {
if (!LOOPBACK_HOSTS.has(host)) {
throw new Error(`预览服务器只允许回环绑定(127.0.0.1 / localhost),拒绝 host=${host}`)
}
if (typeof distDir !== 'string' || distDir === '') {
throw new Error('distDir 缺席——预览需要构建产物目录')
}
if (typeof routeManifest !== 'object' || routeManifest === null || !Array.isArray(routeManifest.routes)) {
throw new Error('routeManifest 形状非法(须含 routes 数组)——预览需要 §12.1 route-manifest')
}
const distRoot = resolve(distDir)
// 匹配索引(构造期一次构建;manifest 行序权威——重复键首个胜出)
const exactByKey = new Map() // query_key 非空的行:exact_key = `path?query`
const byPath = new Map() // query_key 空的行:exact_key 即 path
for (const row of routeManifest.routes) {
if (typeof row?.exact_key !== 'string' || row.exact_key === '') continue
const target = row.query_key != null || row.exact_key.includes('?') ? exactByKey : byPath
if (!target.has(row.exact_key)) target.set(row.exact_key, row)
}
// realpath 根缓存(首个文件请求时解析一次;dist 根须真实存在,否则一切 404)
let realDistRootPromise = null
function realDistRoot() {
if (realDistRootPromise === null) realDistRootPromise = realpath(distRoot)
return realDistRootPromise
}
// 404 自定义页缓存(首个 404 时读取一次;缺席/不可读回退纯文本)
let notFoundCache = undefined
async function notFoundResponse() {
if (notFoundCache === undefined) {
try {
notFoundCache = { body: await readFile(join(distRoot, '404.html')), contentType: 'text/html; charset=utf-8' }
} catch {
notFoundCache = { body: '404 Not Found', contentType: 'text/plain; charset=utf-8' }
}
}
return notFoundCache
}
async function sendNotFound(res, isHead) {
const page = await notFoundResponse()
send(res, 404, page.contentType, page.body, isHead)
}
/** html/asset 行文件服务:target_file 必须解析在 dist 内(lexical + realpath 双检)且是文件 */
async function serveFile(res, row, isHead) {
const rel = typeof row.target_file === 'string' ? row.target_file.replace(/^\/+/, '') : ''
if (rel === '' || rel.includes('\\')) {
await sendNotFound(res, isHead)
return 404
}
const abs = resolve(distRoot, rel)
if (abs !== distRoot && !abs.startsWith(distRoot + sep)) {
await sendNotFound(res, isHead)
return 404
}
let realAbs
let root
try {
;[realAbs, root] = await Promise.all([realpath(abs), realDistRoot()])
} catch {
await sendNotFound(res, isHead)
return 404
}
if (realAbs !== root && !realAbs.startsWith(root + sep)) {
await sendNotFound(res, isHead)
return 404
}
const st = await stat(realAbs)
if (!st.isFile()) {
// 目录(或任何非文件)一律 404——禁目录列表
await sendNotFound(res, isHead)
return 404
}
const body = await readFile(realAbs)
const contentType =
typeof row.content_type === 'string' && row.content_type !== ''
? row.content_type.startsWith('text/html')
? 'text/html; charset=utf-8'
: row.content_type
: contentTypeForExt(rel)
send(res, row.status || 200, contentType, body, isHead)
return row.status || 200
}
async function dispatch(req, res) {
const isHead = req.method === 'HEAD'
if (req.method !== 'GET' && !isHead) {
send(res, 405, 'text/plain; charset=utf-8', '405 Method Not Allowed(预览仅支持 GET/HEAD)', isHead, {
Allow: 'GET, HEAD',
})
return 405
}
let url
try {
// 穿越预检必须先于 URL 解析(见 isUnsafeRequestPath 头注记)
if (req.url === undefined || req.url === '' || isUnsafeRequestPath(req.url)) {
await sendNotFound(res, isHead)
return 404
}
url = new URL(req.url, 'http://preview.local')
} catch {
await sendNotFound(res, isHead)
return 404
}
// 匹配:带查询时先试 exact 全串(query_key 行),未中再剥查询按 path(ignore 行)
let row = null
if (url.search !== '') row = exactByKey.get(url.pathname + url.search) ?? null
if (row === null) row = byPath.get(url.pathname) ?? null
if (row === null) {
await sendNotFound(res, isHead)
return 404
}
if (row.kind === 'html' || row.kind === 'asset') return serveFile(res, row, isHead)
if (row.kind === 'redirect') {
const location = typeof row.location === 'string' ? row.location : '/'
const status = row.status >= 300 && row.status <= 399 ? row.status : 301
send(res, status, 'text/html; charset=utf-8', htmlDocument('Moved', `本页已迁移:<a href="${location}">${location}</a>`), isHead, {
Location: location,
})
return status
}
if (row.kind === 'gone') {
send(res, 410, 'text/html; charset=utf-8', GONE_HTML, isHead)
return 410
}
// dynamic/unresolved:静态 dist 无对应产物——按未命中处理
await sendNotFound(res, isHead)
return 404
}
const server = createServer((req, res) => {
const startedAt = performance.now()
dispatch(req, res)
.then((status) => {
console.log(`[preview] ${req.method} ${req.url} ${status} ${(performance.now() - startedAt).toFixed(1)}ms`)
})
.catch((err) => {
// 兜底:文件读取中途失败等未预期错误——500(若头未发出)
console.error(`[preview] internal error ${req.method} ${req.url}:`, err)
if (!res.headersSent) {
send(res, 500, 'text/plain; charset=utf-8', '500 Internal Server Error', false)
} else {
res.destroy()
}
})
})
function start() {
return new Promise((resolveStart, rejectStart) => {
const onError = (err) => rejectStart(err)
server.once('error', onError)
server.listen(port, host, () => {
server.off('error', onError)
resolveStart(server.address())
})
})
}
function stop() {
return new Promise((resolveStop) => {
// 先destroy连接再close:undici keep-alive 空闲套接字会吊住 close() 回调
if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
server.close(() => resolveStop())
})
}
return { server, start, stop }
}
// ---- 直跑入口(pnpm --filter @sf/preview-server start -- --dist … --manifest …) ----
function parseCliArgs(argv) {
const out = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '--dist' || a === '--manifest' || a === '--port' || a === '--host') {
out[a.slice(2)] = argv[++i]
} else {
throw new Error(`未知参数:${a}(可用:--dist <dir> --manifest <path> [--port 4173] [--host 127.0.0.1])`)
}
}
return out
}
async function main() {
const args = parseCliArgs(process.argv.slice(2))
if (typeof args.dist !== 'string' || typeof args.manifest !== 'string') {
throw new Error('用法:node server.mjs --dist <dir> --manifest <path> [--port 4173] [--host 127.0.0.1]')
}
const port = args.port === undefined ? 4173 : Number(args.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`--port 非法(0-65535 整数):${args.port}`)
const manifest = JSON.parse(await readFile(args.manifest, 'utf8'))
const preview = createPreviewServer({
distDir: args.dist,
routeManifest: manifest,
host: args.host ?? '127.0.0.1',
port,
})
const addr = await preview.start()
console.log(`[preview] dist=${resolve(args.dist)} routes=${manifest.routes.length}`)
console.log(`[preview] listening http://${addr.address}:${addr.port}/ (Ctrl-C 停止)`)
const shutdown = () => {
void preview.stop().then(() => process.exit(0))
}
process.once('SIGINT', shutdown)
process.once('SIGTERM', shutdown)
}
const invokedDirectly =
process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
if (invokedDirectly) {
main().catch((err) => {
console.error(`[preview] ${err instanceof Error ? err.message : String(err)}`)
process.exitCode = 1
})
}