Files
cms-website/apps/preview-server/server.mjs
T
浅念 5f78dde9ee fix(preview): 流式文件服务与 HEAD 跳读;静默开关;redirect 转义;argv 缺值报错
复审四项:
- serveFile 改 createReadStream 直 pipe(Content-Length 取自先行 stat)——
  64MB 文件 RSS 平坦(冒烟实测 -53MB 增长,此前整读驻留);HEAD 只发头
  零读盘;流错误掐断连接不崩进程;头纪律抽 applyHeaders 唯一出口。
- createPreviewServer 增 quiet 选项(缺省 verbose);sf.ts preview:start
  与 server.mjs 直跑入口各加 --quiet;CLI 透传经子进程冒烟验证。
- redirect 迁移页内嵌 location 时 HTML 转义(</&/"——Location 头原样)。
- parseCliArgs 末尾缺值 flag 显式报错(不再静默 undefined 漏成默认值)。

测试 12→13(新增 quiet 开关用例;用例 12 增 --quiet stdout 断言):
根套件 380 + 主题 26 = 406 全绿;typecheck/lint 干净。

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

358 lines
15 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 文件服务(**流式**:createReadStream 直pipe,Content-Length
// 取自先行 stat——大文件不驻留内存;HEAD 只发头零读盘;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
// (quiet: true 静默)。
//
// 本文件刻意保持 .mjs 纯 JavaScript(不进 TS 链);TS 消费方(scripts/sf.ts 与集成
// 测试)的类型面由相邻 server.d.mts 声明供给(NodeNext 解析自动拾取)。
import { createReadStream } from 'node:fs'
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'
}
/** HTML 文本转义(redirect 迁移页内嵌 location 用——Location 头本身保持原样) */
function escapeHtml(s) {
return s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}
/** 头纪律唯一出口:全响应 noindex;text/html 附 CSP;扩展头(Location/Allow 等) */
function applyHeaders(res, status, contentType, 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)
}
/** 固定小体响应出口(404/410/405/redirect 页):HEAD 不写 body(Content-Length 仍按实体设置) */
function send(res, status, contentType, body, isHead, extraHeaders = undefined) {
applyHeaders(res, status, contentType, extraHeaders)
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)。
* quiet: true 静默逐请求访问日志(缺省逐请求一行 stdout)。
*/
export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1', port = 4173, quiet = false }) {
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 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)
// 流式服务:Content-Length 取自先行 stat(body 不经内存驻留——大媒体文件 RSS 平坦);
// HEAD 只发头零读盘(Content-Length 仍按实体设置,符合语义)
applyHeaders(res, row.status || 200, contentType)
res.setHeader('Content-Length', String(st.size))
if (isHead) {
res.end()
return row.status || 200
}
createReadStream(realAbs)
.on('error', (err) => {
// 头已发出无法改码——掐断连接(客户端见截断传输);进程不因未处理 error 崩
console.error(`[preview] 文件流失败 ${rel}:`, err)
res.destroy()
})
.pipe(res)
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
// Location 头原样;HTML 迁移页内嵌时转义(</&/" ——防 manifest location 注入标记)
const safeLocation = escapeHtml(location)
send(res, status, 'text/html; charset=utf-8', htmlDocument('Moved', `本页已迁移:<a href="${safeLocation}">${safeLocation}</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) => {
if (!quiet) {
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') {
const value = argv[i + 1]
if (value === undefined) {
throw new Error(`参数 ${a} 缺值(可用:--dist <dir> --manifest <path> [--port 4173] [--host 127.0.0.1] [--quiet])`)
}
out[a.slice(2)] = value
i++
} else if (a === '--quiet') {
out.quiet = true
} else {
throw new Error(`未知参数:${a}(可用:--dist <dir> --manifest <path> [--port 4173] [--host 127.0.0.1] [--quiet])`)
}
}
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] [--quiet]')
}
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,
quiet: args.quiet === true,
})
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
})
}