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>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@sf/preview-server",
|
||||
"version": "0.1.0",
|
||||
"description": "SiteForge 本地预览服务器(V2 spec §12.2):只读 dist + route-manifest 的路由驱动静态服务——Node http 零依赖、回环绑定、noindex",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// server.mjs 的 TS 声明面(NodeNext 自动拾取):实现刻意保持纯 JS(零依赖、不进 TS
|
||||
// 构建链),类型契约在此钉死。行类型是 RouteManifestRoute 的结构超集容忍面——
|
||||
// @sf/contracts 的 RouteManifest 可直接传入(字段窄化兼容)。
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { Server } from 'node:http'
|
||||
|
||||
export interface PreviewRouteRow {
|
||||
exact_key: string
|
||||
query_policy: string
|
||||
query_key?: string | null
|
||||
kind: string
|
||||
target_file?: string
|
||||
status: number
|
||||
location?: string
|
||||
content_type?: string
|
||||
indexable: boolean
|
||||
}
|
||||
|
||||
export interface PreviewManifestLike {
|
||||
routes: PreviewRouteRow[]
|
||||
}
|
||||
|
||||
export interface PreviewServerOptions {
|
||||
/** 构建产物目录(<artifactsRoot>/jobs/<jobId>/dist) */
|
||||
distDir: string
|
||||
/** §12.1 route-manifest(已解析对象——预览只读不加载路径) */
|
||||
routeManifest: PreviewManifestLike
|
||||
/** 绑定地址;仅 127.0.0.1 / localhost(缺省 127.0.0.1)——外值构造即拒 */
|
||||
host?: string
|
||||
/** 监听端口(缺省 4173;0 = 随机可用端口) */
|
||||
port?: number
|
||||
}
|
||||
|
||||
export interface PreviewServerHandle {
|
||||
server: Server
|
||||
/** 开始监听;resolve 值 = server.address()(port=0 时含实际端口) */
|
||||
start(): Promise<AddressInfo>
|
||||
/** 关停(destroy 连接后 close——防 keep-alive 套接字吊住) */
|
||||
stop(): Promise<void>
|
||||
}
|
||||
|
||||
export declare function createPreviewServer(options: PreviewServerOptions): PreviewServerHandle
|
||||
@@ -0,0 +1,317 @@
|
||||
// 本地预览服务器(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
|
||||
})
|
||||
}
|
||||
+6
-4
@@ -27,10 +27,10 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
// Node 直跑的 .mjs 工具脚本(如 scripts/smoke-real-wxr.mjs):no-undef 对 TS 文件
|
||||
// 由 TS 接管而关闭,但 .mjs 仍生效且无 node 全局——此处显式声明 node 全局,
|
||||
// 否则 process/setInterval 等误报 no-undef。
|
||||
files: ['scripts/**/*.mjs'],
|
||||
// Node 直跑的 .mjs 工具脚本(如 scripts/smoke-real-wxr.mjs、apps/preview-server/
|
||||
// server.mjs):no-undef 对 TS 文件由 TS 接管而关闭,但 .mjs 仍生效且无 node
|
||||
// 全局——此处显式声明 node 全局,否则 process/setInterval/URL 等误报 no-undef。
|
||||
files: ['scripts/**/*.mjs', 'apps/preview-server/**/*.mjs'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
process: 'readonly',
|
||||
@@ -41,6 +41,8 @@ export default tseslint.config(
|
||||
console: 'readonly',
|
||||
performance: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
URL: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+72
-2
@@ -1,20 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
// sf CLI(Plan 2 Task 7 + Plan 3 Task 1 + Plan 4 Task 1-2):commander 五命令——
|
||||
// sf CLI(Plan 2 Task 7 + Plan 3 Task 1 + Plan 4 Task 1-2/7):commander 六命令——
|
||||
// - wxr:probe:只读诊断(任意可读路径;Task 8 直读 cms 用此通道),出 JSON/HTML 报告不入库;
|
||||
// - source:register:调 registerSource 登记 content-addressed 快照(路径白名单校验在服务内);
|
||||
// - import:调 createImportJob 建 import_jobs 并推进生命周期(Task 6:默认编排管道——四家写入器汇流);
|
||||
// - content:publish:调 publishSiteContent 显式发布(dry-run 清单+confirm token → --confirm 执行);
|
||||
// - build:create:调 createBuildJob 冻结 Render Snapshot(六文件物化 + build_input_snapshots;dist 是 Task 5)。
|
||||
// - preview:start:路由驱动的本地预览(Task 7,V2 spec §12.2)——succeeded job 的 dist +
|
||||
// route-manifest 只读服务(回环绑定、noindex、零新依赖 node:http)。
|
||||
// 执行机制(Plan 2 评审钉死):根 devDep tsx 直吃 TS 源码(含 apps/api 的 Nest 源码与
|
||||
// packages 的装饰器/TS 源文件),@sf/* 经根/包 tsconfig paths 直连 src——无需预构建 dist。
|
||||
// 注意:CLI 只 import register.service.ts / import.service.ts / publish.service.ts /
|
||||
// build.service.ts(零装饰器纯函数文件),不拉起 Nest 模块——反射元数据依赖由此避免;module 文件留给 standalone context。
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Command } from 'commander'
|
||||
import { renderReportHtml, runWxrAdapterReport, type WxrAdapterReport } from '@sf/adapter-wp'
|
||||
import { isRouteManifest } from '@sf/contracts'
|
||||
import { getDb } from '../apps/api/src/db/client.js'
|
||||
import { PipelineError } from '../apps/api/src/modules/build/pipeline.js'
|
||||
import { BuildError, createBuildJob } from '../apps/api/src/modules/build/build.service.js'
|
||||
@@ -349,6 +352,73 @@ program
|
||||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command('preview:start')
|
||||
.description('路由驱动的本地预览(V2 spec §12.2):只读 succeeded job 的 dist + route-manifest——不查库、回环绑定、X-Robots-Tag noindex')
|
||||
.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 }) => {
|
||||
// 回环校验先于一切(含 DB 访问)——预览永不对外,外值在触碰任何资源前拒绝
|
||||
if (opts.host !== '127.0.0.1' && opts.host !== 'localhost') {
|
||||
throw new Error(`--host 仅允许 127.0.0.1 / localhost(回环绑定纪律,外值拒绝):${opts.host}`)
|
||||
}
|
||||
const port = Number(opts.port)
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
throw new Error(`--port 非法(0-65535 整数):${opts.port}`)
|
||||
}
|
||||
// 只读 job 行 + 产物登记行——读毕即断池(预览期不查库:§12.2 纪律)。
|
||||
// 查询走裸 SQL(drizzle-orm 非根依赖,pnpm 严格布局下 scripts/ 解析不到——与
|
||||
// 集成测试同款的 db.execute 通道);job id 先做 uuid 形状校验再内插。
|
||||
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(opts.build)) {
|
||||
throw new Error(`--build 不是合法 job id(uuid 形状):${opts.build}`)
|
||||
}
|
||||
const db = getDb()
|
||||
let distPath: string | undefined
|
||||
let manifestPath: string | undefined
|
||||
try {
|
||||
const jobs = (await db.execute(
|
||||
`select id, status from build_jobs where id = '${opts.build}'`,
|
||||
)) as Array<{ id: string; status: string }>
|
||||
if (jobs.length === 0) {
|
||||
throw new Error(`构建 job 不存在:${opts.build}`)
|
||||
}
|
||||
const job = jobs[0]!
|
||||
if (job.status !== 'succeeded') {
|
||||
throw new Error(`仅 succeeded job 可预览(job ${opts.build} 当前状态 ${job.status})——dist 产物只在成功终态存在`)
|
||||
}
|
||||
const arts = (await db.execute(
|
||||
`select kind, storage_path from build_artifacts where build_job_id = '${opts.build}'`,
|
||||
)) as Array<{ kind: string; storage_path: string }>
|
||||
distPath = arts.find((a) => a.kind === 'dist')?.storage_path
|
||||
manifestPath = arts.find((a) => a.kind === 'manifest')?.storage_path
|
||||
if (distPath === undefined || manifestPath === undefined) {
|
||||
throw new Error(`job ${opts.build} 产物登记不全(dist/manifest 行缺席)——产物面破裂,拒绝预览`)
|
||||
}
|
||||
} finally {
|
||||
await (db as { $client: { end: () => Promise<void> } }).$client.end()
|
||||
}
|
||||
const manifestRaw: unknown = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
if (!isRouteManifest(manifestRaw)) {
|
||||
throw new Error(`route-manifest 结构校验失败(§12.1):${manifestPath}`)
|
||||
}
|
||||
// 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 addr = await preview.start()
|
||||
console.log(`[preview:start] job=${opts.build}`)
|
||||
console.log(` dist: ${distPath}`)
|
||||
console.log(` routes: ${manifestRaw.routes.length} 行(route-manifest 驱动;X-Robots-Tag: noindex, nofollow)`)
|
||||
console.log(` listening: http://${opts.host}:${addr.port}/ (Ctrl-C 停止)`)
|
||||
const shutdown = (): void => {
|
||||
void preview.stop().then(() => {
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
process.once('SIGINT', shutdown)
|
||||
process.once('SIGTERM', shutdown)
|
||||
})
|
||||
|
||||
// parseAsync:action 内抛错走此 catch(commander 默认会 process.exit(1) 并打印堆栈,
|
||||
// 这里收敛为单行错误信息 + exitCode——SourceRegisterError/ImportError/PublishError/BuildError/PipelineError 打 code,其余原样呈现)
|
||||
program.parseAsync(process.argv).catch((err: unknown) => {
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
// Plan 4 Task 7 失败测试(先于实现):路由驱动的本地预览服务器(V2 spec §12.2)。
|
||||
// 夹具策略:先跑一次 createBuildJob 全链(真实构建产物 = mini 夹具站点 succeeded job),
|
||||
// 产物 dist + route-manifest 复用于全部用例;manifest 是 createPreviewServer 的独立入参,
|
||||
// 故 gone 行与扩展名映射探针行直接手构注入(真实数据暂无 kind=gone——§12.1 契约先行)。
|
||||
// 覆盖契约(对照计划 Task 7 十条):
|
||||
// 1. html 行 200 + Content-Type text/html; charset=utf-8(body = dist 产物逐字节)
|
||||
// 2. redirect 行 301 + Location(/?attachment_id=201 查询形态——exact_key 含查询串)
|
||||
// 3. asset 行 200 + 正确 MIME(png 真实行 content_type;jpg/webp/pdf 扩展名映射;
|
||||
// 未知扩展 → application/octet-stream)
|
||||
// 4. 未命中 404 + dist/404.html 自定义页(Content-Type html)
|
||||
// 5. gone 行 410 + 简短 HTML 说明(手构 manifest 行)
|
||||
// 6. 全响应 X-Robots-Tag: noindex, nofollow;HTML 响应基础 CSP(default-src 'self')
|
||||
// 7. 绑定 127.0.0.1(server.address() 断言)+ host 外值拒绝(库级 + CLI 级 spawn)
|
||||
// 8. 路径穿越拒绝:/../、%2e%2e%2f、..%5c、反斜杠 → 400/404 且不读盘外文件
|
||||
// 9. 目录列表禁用(dist 子目录请求 → 404,不出文件清单)
|
||||
// 10. HEAD 方法支持(200 同头无 body)
|
||||
// 11. CLI:--host 外值拒绝(校验先于 DB 访问——tsx 直跑 sf.ts)
|
||||
// 12. CLI happy path:真实 job 子进程全链(job 定位 → manifest 读 → 监听可服务 → 关停)
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
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 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'
|
||||
import { publishSiteContent } from '../../apps/api/src/modules/publish/publish.service.js'
|
||||
import { registerSource } from '../../apps/api/src/modules/source/register.service.js'
|
||||
import { getDb } from '../../apps/api/src/db/client.js'
|
||||
import { rand, withImmutableCleanup, type Db } from './helpers.js'
|
||||
import { createPreviewServer, type PreviewServerHandle } from '../../apps/preview-server/server.mjs'
|
||||
|
||||
/** 本文件位于 <repo>/tests/integration/ → 上两级是仓库根 */
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url))
|
||||
const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml')
|
||||
const TSX_CLI = join(REPO_ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs')
|
||||
|
||||
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
|
||||
function mediaBytes(seed: string): Buffer {
|
||||
return Buffer.concat([PNG_SIG, Buffer.from(`media-body:${seed}`, 'latin1')])
|
||||
}
|
||||
function sha256of(buf: Buffer | string): string {
|
||||
return createHash('sha256').update(buf).digest('hex')
|
||||
}
|
||||
|
||||
interface Work {
|
||||
work: string
|
||||
storageRoot: string
|
||||
allowedRoots: string[]
|
||||
pngA: Buffer
|
||||
}
|
||||
|
||||
async function makeWork(prefix: string): Promise<Work> {
|
||||
const work = await mkdtemp(join(tmpdir(), `${prefix}-`))
|
||||
const mediaDir = join(work, 'media')
|
||||
await mkdir(join(mediaDir, '2026', '10'), { recursive: true })
|
||||
const pngA = mediaBytes(`preview-a-${rand()}`)
|
||||
await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), pngA)
|
||||
await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), mediaBytes(`preview-b-${rand()}`))
|
||||
return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work], pngA }
|
||||
}
|
||||
|
||||
/** mini 全链(register wxr+media → import → publish):3 条 published(build-pipeline 同款) */
|
||||
async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> {
|
||||
const key = `sfpreview-${rand()}`
|
||||
const reg = await registerSource(db, {
|
||||
siteKey: key,
|
||||
wxrPath: WXR_FIXTURE,
|
||||
mediaDir: join(w.work, 'media'),
|
||||
allowedRoots: w.allowedRoots,
|
||||
storageRoot: w.storageRoot,
|
||||
reportRoot: join(w.work, 'artifacts', 'register'),
|
||||
})
|
||||
const result = await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') })
|
||||
expect(result.status).toBe('succeeded_with_issues')
|
||||
const dry = await publishSiteContent(db, { siteKey: key, dryRun: true })
|
||||
const run = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! })
|
||||
expect(run.publishedCount).toBe(3)
|
||||
return { key, siteId: reg.siteId }
|
||||
}
|
||||
|
||||
/** 本文件写入的行清场(build-pipeline 同款反序:validation 域先行 + 构建域 + 全链) */
|
||||
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
|
||||
await db.execute(`delete from validation_issues where validation_run_id in
|
||||
(select id from validation_runs where site_id = '${siteId}')`)
|
||||
await db.execute(`delete from validation_runs where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from build_artifacts where build_job_id in
|
||||
(select id from build_jobs where site_id = '${siteId}')`)
|
||||
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
|
||||
await db.execute(`delete from build_jobs where site_id = '${siteId}'`)
|
||||
await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `site_id = '${siteId}'`)
|
||||
await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from route_registry where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from media_usages where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from media_assets where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from content_terms where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from terms where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from taxonomies where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from source_records where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from entry_locales where site_id = '${siteId}'`)
|
||||
await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`)
|
||||
await db.execute(`delete from content_entries where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from import_jobs where site_id = '${siteId}'`)
|
||||
await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`)
|
||||
await db.execute(`delete from source_connections where site_id = '${siteId}'`)
|
||||
await db.execute(`delete from sites where id = '${siteId}'`)
|
||||
}
|
||||
|
||||
/** 原样路径请求(fetch/URL 会规范化 '/../' 等点段——穿越用例必须走 node:http 裸发) */
|
||||
function rawRequest(
|
||||
port: number,
|
||||
rawPath: string,
|
||||
method: 'GET' | 'HEAD' = 'GET',
|
||||
): Promise<{ status: number | undefined; headers: Record<string, string | string[] | undefined>; body: Buffer }> {
|
||||
return new Promise((resolveP, rejectP) => {
|
||||
const req = request({ host: '127.0.0.1', port, method, path: rawPath }, (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (c: Buffer) => chunks.push(c))
|
||||
res.on('end', () => resolveP({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }))
|
||||
})
|
||||
req.on('error', rejectP)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', () => {
|
||||
let db: Db | undefined
|
||||
let work: Work | undefined
|
||||
let siteId = ''
|
||||
let distDir = ''
|
||||
let manifest: RouteManifest | undefined
|
||||
let preview: PreviewServerHandle | undefined
|
||||
let port = 0
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getDb()
|
||||
work = await makeWork('sf-preview')
|
||||
const seed = await seedPublishedSite(db, work)
|
||||
siteId = seed.siteId
|
||||
const result = await createBuildJob(db, {
|
||||
siteKey: seed.key,
|
||||
artifactsRoot: join(work.work, 'artifacts'),
|
||||
})
|
||||
expect(result.status).toBe('succeeded')
|
||||
expect(result.artifacts).toBeDefined()
|
||||
distDir = result.artifacts!.distPath
|
||||
manifest = JSON.parse(await readFile(result.artifacts!.routeManifestPath, 'utf8')) as RouteManifest
|
||||
|
||||
// 扩展名映射探针文件(真实构建产物外补——jpg/webp/pdf/未知扩展;行不带 content_type,
|
||||
// 迫使服务端走扩展名→MIME 表)+ gone 行(真实数据暂无,§12.1 契约先行手构)
|
||||
const probes: Array<{ file: string; route: string }> = [
|
||||
{ file: 'mimeprobe/photo.jpg', route: '/mimeprobe/photo.jpg' },
|
||||
{ file: 'mimeprobe/graph.webp', route: '/mimeprobe/graph.webp' },
|
||||
{ file: 'mimeprobe/doc.pdf', route: '/mimeprobe/doc.pdf' },
|
||||
{ file: 'mimeprobe/blob.xyz', route: '/mimeprobe/blob.xyz' },
|
||||
]
|
||||
for (const p of probes) {
|
||||
await mkdir(dirname(join(distDir, p.file)), { recursive: true })
|
||||
await writeFile(join(distDir, p.file), `probe-body:${p.route}\n`, 'utf8')
|
||||
}
|
||||
const probeRows: RouteManifestRoute[] = probes.map((p) => ({
|
||||
exact_key: p.route,
|
||||
query_policy: 'ignore',
|
||||
query_key: null,
|
||||
kind: 'asset',
|
||||
target_file: p.file,
|
||||
status: 200,
|
||||
indexable: false,
|
||||
}))
|
||||
const goneRow: RouteManifestRoute = {
|
||||
exact_key: '/gone-page/',
|
||||
query_policy: 'ignore',
|
||||
query_key: null,
|
||||
kind: 'gone',
|
||||
status: 410,
|
||||
indexable: false,
|
||||
}
|
||||
const extended: RouteManifest = { ...manifest!, routes: [...manifest!.routes, ...probeRows, goneRow] }
|
||||
|
||||
preview = createPreviewServer({ distDir, routeManifest: extended, host: '127.0.0.1', port: 0 })
|
||||
const addr = await preview.start()
|
||||
expect(addr.address).toBe('127.0.0.1')
|
||||
port = addr.port
|
||||
}, 300_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await preview?.stop()
|
||||
if (db !== undefined) {
|
||||
try {
|
||||
if (siteId !== '') await cleanupSiteRows(db, siteId)
|
||||
} finally {
|
||||
await (db as { $client: { end: () => Promise<void> } }).$client.end()
|
||||
}
|
||||
}
|
||||
if (work !== undefined) await rm(work.work, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function get(p: string): Promise<{ status: number; headers: Headers; text: string; bytes: Buffer }> {
|
||||
const res = await fetch(new URL(p, `http://127.0.0.1:${port}/`))
|
||||
const ab = await res.arrayBuffer()
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
text: new TextDecoder().decode(ab),
|
||||
bytes: Buffer.from(ab),
|
||||
}
|
||||
}
|
||||
|
||||
it('1. html 行 200 + Content-Type text/html; charset=utf-8(body 逐字节 = dist 产物)', async () => {
|
||||
const res = await get('/')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8')
|
||||
expect(res.bytes).toStrictEqual(await readFile(join(distDir, 'index.html')))
|
||||
})
|
||||
|
||||
it('2. redirect 行 301 + Location(?attachment_id=201 查询形态命中 query_key 行)', async () => {
|
||||
const res = await fetch(new URL('/?attachment_id=201', `http://127.0.0.1:${port}/`), {
|
||||
redirect: 'manual',
|
||||
})
|
||||
expect(res.status).toBe(301)
|
||||
expect(res.headers.get('location')).toBe('/wp-content/uploads/2026/10/fixture-a.png')
|
||||
})
|
||||
|
||||
it('3. asset 行 200 + MIME:png 真实行 content_type;jpg/webp/pdf 扩展名映射;未知扩展 octet-stream', async () => {
|
||||
const png = await get('/wp-content/uploads/2026/10/fixture-a.png')
|
||||
expect(png.status).toBe(200)
|
||||
expect(png.headers.get('content-type')).toBe('image/png')
|
||||
expect(sha256of(png.bytes)).toBe(sha256of(work!.pngA))
|
||||
|
||||
const jpg = await get('/mimeprobe/photo.jpg')
|
||||
expect(jpg.status).toBe(200)
|
||||
expect(jpg.headers.get('content-type')).toBe('image/jpeg')
|
||||
|
||||
const webp = await get('/mimeprobe/graph.webp')
|
||||
expect(webp.status).toBe(200)
|
||||
expect(webp.headers.get('content-type')).toBe('image/webp')
|
||||
|
||||
const pdf = await get('/mimeprobe/doc.pdf')
|
||||
expect(pdf.status).toBe(200)
|
||||
expect(pdf.headers.get('content-type')).toBe('application/pdf')
|
||||
|
||||
const xyz = await get('/mimeprobe/blob.xyz')
|
||||
expect(xyz.status).toBe(200)
|
||||
expect(xyz.headers.get('content-type')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
it('4. 未命中 404 + dist/404.html 自定义页(Content-Type html)', async () => {
|
||||
const res = await get('/definitely-not-a-route/')
|
||||
expect(res.status).toBe(404)
|
||||
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8')
|
||||
expect(res.text).toStrictEqual(await readFile(join(distDir, '404.html'), 'utf8'))
|
||||
})
|
||||
|
||||
it('5. gone 行 410 + 简短 HTML 说明(手构 manifest 行)', async () => {
|
||||
const res = await get('/gone-page/')
|
||||
expect(res.status).toBe(410)
|
||||
expect(res.headers.get('content-type')?.startsWith('text/html')).toBe(true)
|
||||
expect(res.text).toContain('410')
|
||||
expect(res.text.length).toBeLessThan(1024)
|
||||
})
|
||||
|
||||
it('6. 全响应 X-Robots-Tag: noindex, nofollow;HTML 响应 CSP default-src self(asset 无 CSP)', async () => {
|
||||
for (const p of ['/', '/definitely-not-a-route/', '/gone-page/', '/wp-content/uploads/2026/10/fixture-a.png']) {
|
||||
const res = await get(p)
|
||||
expect(res.headers.get('x-robots-tag'), p).toBe('noindex, nofollow')
|
||||
}
|
||||
expect((await get('/')).headers.get('content-security-policy')).toBe("default-src 'self'")
|
||||
expect((await get('/definitely-not-a-route/')).headers.get('content-security-policy')).toBe("default-src 'self'")
|
||||
expect((await get('/wp-content/uploads/2026/10/fixture-a.png')).headers.get('content-security-policy')).toBeNull()
|
||||
})
|
||||
|
||||
it('7. 绑定 127.0.0.1(server.address 断言)+ host 外值拒绝(库级同步拒绝)', () => {
|
||||
expect(port).toBeGreaterThan(0)
|
||||
expect(
|
||||
() => createPreviewServer({ distDir, routeManifest: manifest!, host: '0.0.0.0', port: 0 }),
|
||||
).toThrow(/127\.0\.0\.1|localhost|回环/)
|
||||
// localhost 是另一合法回环值——构造成功(不 start,无监听副作用)
|
||||
expect(
|
||||
createPreviewServer({ distDir, routeManifest: manifest!, host: 'localhost', port: 0 }),
|
||||
).toBeTypeOf('object')
|
||||
})
|
||||
|
||||
it('8. 路径穿越拒绝:/../、%2e%2e%2f、..%5c、反斜杠 → 400/404,不读盘外', async () => {
|
||||
const attacks = [
|
||||
'/../',
|
||||
'/..',
|
||||
'/%2e%2e%2f',
|
||||
'/%2E%2E%2F',
|
||||
'/..%5c',
|
||||
'/%2e%2e%5c',
|
||||
'/\\',
|
||||
'/\\..\\..',
|
||||
'/wp-content/uploads/2016/../../../route-manifest.json',
|
||||
'/%2e%2e/route-manifest.json',
|
||||
]
|
||||
for (const a of attacks) {
|
||||
const res = await rawRequest(port, a)
|
||||
expect([400, 404], `攻击路径 ${a} 必须被拒绝(不读盘外)`).toContain(res.status)
|
||||
// 不读盘外:响应体绝不包含 route-manifest.json 的内容标记(dist 上一级的文件)
|
||||
expect(res.body.toString('utf8')).not.toContain('generated_from_route_snapshot_hash')
|
||||
}
|
||||
})
|
||||
|
||||
it('9. 目录列表禁用:dist 子目录请求 → 404 且不出文件清单', async () => {
|
||||
for (const d of ['/wp-content/uploads/2026/10/', '/wp-content/', '/mimeprobe/']) {
|
||||
const res = await get(d)
|
||||
expect(res.status, d).toBe(404)
|
||||
// 不是目录清单:响应是 404 页(自定义页在场时逐字节等于 404.html),不含目录内文件名
|
||||
expect(res.text).not.toContain('fixture-a.png')
|
||||
expect(res.text).not.toContain('fixture-b.png')
|
||||
}
|
||||
})
|
||||
|
||||
it('10. HEAD 方法支持:200 同头无 body', async () => {
|
||||
const res = await rawRequest(port, '/', 'HEAD')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toBe('text/html; charset=utf-8')
|
||||
expect(res.headers['x-robots-tag']).toBe('noindex, nofollow')
|
||||
expect(res.body.byteLength).toBe(0)
|
||||
})
|
||||
|
||||
it('11. CLI host 外值拒绝(tsx 直跑 sf.ts:校验先于 DB 访问,stderr 单行拒绝)', { timeout: 60_000 }, async () => {
|
||||
const outcome = await new Promise<{ err: Error | null; stderr: string }>((resolveP) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[
|
||||
TSX_CLI,
|
||||
join(REPO_ROOT, 'scripts', 'sf.ts'),
|
||||
'preview:start',
|
||||
'--build',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'--host',
|
||||
'0.0.0.0',
|
||||
],
|
||||
{ cwd: REPO_ROOT },
|
||||
(err, _stdout, stderr) => resolveP({ err, stderr: String(stderr) }),
|
||||
)
|
||||
})
|
||||
// 非零退出(拒绝)+ 单行错误信息带外值与回环纪律
|
||||
expect(outcome.err).toBeInstanceOf(Error)
|
||||
expect(outcome.stderr).toContain('--host')
|
||||
expect(outcome.stderr).toContain('0.0.0.0')
|
||||
expect(outcome.stderr).toMatch(/127\.0\.0\.1|localhost|回环/)
|
||||
})
|
||||
|
||||
it('12. CLI happy path:preview:start 真实 job → 监听可服务 → 关停(子进程全链冒烟)', { timeout: 90_000 }, async () => {
|
||||
expect(manifest).toBeDefined()
|
||||
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'],
|
||||
{ cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
)
|
||||
let stdout = ''
|
||||
child.stdout.on('data', (c: Buffer) => {
|
||||
stdout += c.toString('utf8')
|
||||
})
|
||||
child.stderr.on('data', () => {}) // 排水:避免 stderr 管道塞满吊住子进程
|
||||
try {
|
||||
// 轮询等 listening 行(port=0 实际端口从中解析)
|
||||
let cliPort = 0
|
||||
const deadline = Date.now() + 60_000
|
||||
while (Date.now() < deadline) {
|
||||
const m = /listening: http:\/\/127\.0\.0\.1:(\d+)\//.exec(stdout)
|
||||
if (m !== null) {
|
||||
cliPort = Number(m[1])
|
||||
break
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
}
|
||||
expect(cliPort, `CLI 未输出 listening 行(stdout=${stdout})`).toBeGreaterThan(0)
|
||||
// 真实 http 服务可用:首页 200 + noindex 头(DB 已断池——纯 dist+manifest 服务)
|
||||
const res = await fetch(`http://127.0.0.1:${cliPort}/`)
|
||||
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'))
|
||||
} finally {
|
||||
child.kill()
|
||||
await new Promise<void>((resolveP) => {
|
||||
child.once('exit', () => resolveP())
|
||||
setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
resolveP()
|
||||
}, 10_000).unref()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user