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>
This commit is contained in:
浅念
2026-10-22 13:58:15 +08:00
parent 7194d1bcef
commit 5f78dde9ee
4 changed files with 95 additions and 19 deletions
+2
View File
@@ -29,6 +29,8 @@ export interface PreviewServerOptions {
host?: string
/** 监听端口(缺省 4173;0 = 随机可用端口) */
port?: number
/** 静默逐请求访问日志(缺省 false——逐请求一行 stdout) */
quiet?: boolean
}
export interface PreviewServerHandle {
+54 -14
View File
@@ -3,17 +3,21 @@
// 不写盘、零新依赖(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 自定义页)。
// 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。
// (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'
@@ -72,8 +76,13 @@ 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) {
/** 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)
@@ -81,6 +90,11 @@ function send(res, status, contentType, body, isHead, 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)
}
@@ -95,8 +109,9 @@ const GONE_HTML = htmlDocument('410 Gone', '此内容已被永久移除(route-
* 创建预览服务器(不监听——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 }) {
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}`)
}
@@ -172,14 +187,27 @@ export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1'
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)
// 流式服务: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
}
@@ -215,7 +243,9 @@ export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1'
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 头原样;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
@@ -233,7 +263,9 @@ export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1'
const startedAt = performance.now()
dispatch(req, res)
.then((status) => {
console.log(`[preview] ${req.method} ${req.url} ${status} ${(performance.now() - startedAt).toFixed(1)}ms`)
if (!quiet) {
console.log(`[preview] ${req.method} ${req.url} ${status} ${(performance.now() - startedAt).toFixed(1)}ms`)
}
})
.catch((err) => {
// 兜底:文件读取中途失败等未预期错误——500(若头未发出)
@@ -275,9 +307,16 @@ function parseCliArgs(argv) {
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]
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])`)
throw new Error(`未知参数:${a}(可用:--dist <dir> --manifest <path> [--port 4173] [--host 127.0.0.1] [--quiet])`)
}
}
return out
@@ -286,7 +325,7 @@ function parseCliArgs(argv) {
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]')
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}`)
@@ -296,6 +335,7 @@ async function main() {
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}`)