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:
浅念
2026-10-22 13:29:30 +08:00
parent 0efec4673b
commit 7194d1bcef
6 changed files with 842 additions and 6 deletions
+72 -2
View File
@@ -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) => {