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}`)
+9 -2
View File
@@ -358,7 +358,8 @@ program
.requiredOption('--build <jobId>', '构建 job id(须 succeeded——dist 产物只在成功终态存在)')
.option('--port <port>', '监听端口(0 = 随机可用端口)', '4173')
.option('--host <host>', '绑定地址——仅允许 127.0.0.1 / localhost(回环纪律,外值拒绝)', '127.0.0.1')
.action(async (opts: { build: string; port: string; host: string }) => {
.option('--quiet', '静默逐请求访问日志(缺省逐请求一行 stdout)')
.action(async (opts: { build: string; port: string; host: string; quiet?: boolean }) => {
// 回环校验先于一切(含 DB 访问)——预览永不对外,外值在触碰任何资源前拒绝
if (opts.host !== '127.0.0.1' && opts.host !== 'localhost') {
throw new Error(`--host 仅允许 127.0.0.1 / localhost(回环绑定纪律,外值拒绝):${opts.host}`)
@@ -404,7 +405,13 @@ program
}
// server.mjs 是纯 JS 包(零依赖、不进 TS 链)——动态导入直连源文件,类型面由 server.d.mts 供给
const { createPreviewServer } = await import('../apps/preview-server/server.mjs')
const preview = createPreviewServer({ distDir: distPath, routeManifest: manifestRaw, host: opts.host, port })
const preview = createPreviewServer({
distDir: distPath,
routeManifest: manifestRaw,
host: opts.host,
port,
quiet: opts.quiet === true,
})
const addr = await preview.start()
console.log(`[preview:start] job=${opts.build}`)
console.log(` dist: ${distPath}`)
+30 -3
View File
@@ -15,7 +15,9 @@
// 9. 目录列表禁用(dist 子目录请求 → 404,不出文件清单)
// 10. HEAD 方法支持(200 同头无 body)
// 11. CLI:--host 外值拒绝(校验先于 DB 访问——tsx 直跑 sf.ts)
// 12. CLI happy path:真实 job 子进程全链(job 定位 → manifest 读 → 监听可服务 → 关停)
// 12. CLI happy path:真实 job 子进程全链(job 定位 → manifest 读 → 监听可服务 → 关停;
// --quiet 透传——子进程 stdout 无逐请求访问日志)
// 13. quiet 开关:quiet:true 静默逐请求日志;缺省(verbose)逐请求一行 stdout
import { execFile, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -23,7 +25,7 @@ import { request } from 'node:http'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import type { RouteManifest, RouteManifestRoute } from '@sf/contracts'
import { createBuildJob } from '../../apps/api/src/modules/build/build.service.js'
import { createImportJob } from '../../apps/api/src/modules/import/import.service.js'
@@ -353,7 +355,7 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', ()
const jobId = manifest!.build_job_id
const child = spawn(
process.execPath,
[TSX_CLI, join(REPO_ROOT, 'scripts', 'sf.ts'), 'preview:start', '--build', jobId, '--port', '0'],
[TSX_CLI, join(REPO_ROOT, 'scripts', 'sf.ts'), 'preview:start', '--build', jobId, '--port', '0', '--quiet'],
{ cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
)
let stdout = ''
@@ -379,6 +381,9 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', ()
expect(res.status).toBe(200)
expect(res.headers.get('x-robots-tag')).toBe('noindex, nofollow')
expect(await res.text()).toStrictEqual(await readFile(join(distDir, 'index.html'), 'utf8'))
// --quiet 透传:服务过请求后子进程 stdout 仍无逐请求访问日志行
await new Promise((r) => setTimeout(r, 500))
expect(stdout).not.toMatch(/\[preview\] (GET|HEAD)/)
} finally {
child.kill()
await new Promise<void>((resolveP) => {
@@ -390,4 +395,26 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', ()
})
}
})
it('13. quiet 开关:quiet:true 静默逐请求日志;缺省 verbose 逐请求一行', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
try {
const previewLines = (): number => logSpy.mock.calls.filter((c) => String(c[0]).includes('[preview] ')).length
// quiet 实例:请求零访问日志
const quietServer = createPreviewServer({ distDir, routeManifest: manifest!, port: 0, quiet: true })
const addr = await quietServer.start()
try {
expect((await fetch(`http://127.0.0.1:${addr.port}/`)).status).toBe(200)
expect(previewLines()).toBe(0)
} finally {
await quietServer.stop()
}
// 缺省 verbose 对照(beforeAll 主服务):请求恰一行访问日志
const before = previewLines()
await get('/')
expect(previewLines()).toBe(before + 1)
} finally {
logSpy.mockRestore()
}
})
})