Files
浅念 c64f6ac48a test(evidence): 阶段4端到端预览验收
Plan 4 Task 8:真实 hesupipe 全链(register 副本复用 → import → publish
→ build → preview)临时库端到端验收,断言 21/21 PASS + 浏览器时刻 6 截图
无破版无横向溢出。新增 scripts/e2e-real.mjs(chain/serve 两子命令)与
docs/evidence/phase4-preview.md + phase4-assertions.json + screenshots/。

验收内修补(预览服务器,均有回归测试锁定):
- Location 头 ASCII 化:非 ASCII 码位按 UTF-8 百分号编码——原样 setHeader
  触发 ERR_INVALID_CHAR 崩 500 或 latin1 mojibake(1746 redirect 全量
  校验显影 2+3 例);manifest 原串保持权威,测试 2b 锁定。
- CSP 放行内联样式:Astro 5 scoped 样式内联 <style> 被 default-src 'self'
  拦截(浏览器时刻显影,HTTP 级不可见);测试 6 断言同步。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 14:33:55 +08:00

382 lines
16 KiB
JavaScript
Raw Permalink 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
// (**头域 ASCII 化**:manifest location 含非 ASCII 码位(如全角括号/间隔号)时按
// UTF-8 百分号编码后发头——RFC 9110 头域是 ASCII,原样 setHeader 会 ERR_INVALID_CHAR
// 崩成 500 或被对端按 latin1 误解码;manifest 原串保持权威,线上形态是派生面);
// 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'; style-src 'self' 'unsafe-inline'——Astro 内联 scoped 样式放行,
// 见常量处注记)。请求日志一行(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'
// CSP(Task 8 浏览器验收实证修补):Astro 5 静态产物把 scoped 样式内联为 <style> 元素
// (无外部样式表)——default-src 'self' 会拦掉全部内联样式,页面在浏览器里裸奔(HTTP
// 级测试看不到,浏览器时刻才显影)。style-src 放开 'unsafe-inline'(仅样式面;脚本/
// 连接/图片等仍走 default-src 'self')。
const CSP = "default-src 'self'; style-src 'self' 'unsafe-inline'"
/**
* 请求路径安全预检(**先于 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 用——页面体内嵌原文,头走 asciiLocation) */
function escapeHtml(s) {
return s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}
/**
* Location 头 ASCII 化(Task 8 全量 1746 redirect 校验实证修补):非 ASCII 码位按
* UTF-8 百分号编码(encodeURIComponent 单码位即该码位的 UTF-8 八位组,hex 大写——
* 与 WP/nginx 的百分号编码形态一致);可打印 ASCII(含既有 %xx 序列的字面字符)原样。
* 控制字符/CR/LF 同路编码——头注入面封死(值内裸 % 后非 hex 也只是保留字面,不解码)。
*/
function asciiLocation(value) {
let out = ''
for (const ch of value) {
const code = ch.codePointAt(0)
out += code >= 0x20 && code <= 0x7e ? ch : encodeURIComponent(ch)
}
return out
}
/** 头纪律唯一出口:全响应 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 头 ASCII 化后发(manifest 原串含全角括号/间隔号等非 ASCII 时原样发头会
// ERR_INVALID_CHAR 崩 500——Task 8 全量 redirect 校验实证);HTML 迁移页内嵌原文转义
const safeLocation = escapeHtml(location)
send(res, status, 'text/html; charset=utf-8', htmlDocument('Moved', `本页已迁移:<a href="${safeLocation}">${safeLocation}</a>`), isHead, {
Location: asciiLocation(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
})
}