From 7194d1bcef44acf373f2081adaf1dec0daed5007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 22 Oct 2026 13:29:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(preview):=20=E8=B7=AF=E7=94=B1=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=E7=9A=84=E6=9C=AC=E5=9C=B0=E9=A2=84=E8=A7=88=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/preview-server/package.json | 12 + apps/preview-server/server.d.mts | 42 +++ apps/preview-server/server.mjs | 317 ++++++++++++++++++ eslint.config.js | 10 +- scripts/sf.ts | 74 ++++- tests/integration/preview-server.test.ts | 393 +++++++++++++++++++++++ 6 files changed, 842 insertions(+), 6 deletions(-) create mode 100644 apps/preview-server/package.json create mode 100644 apps/preview-server/server.d.mts create mode 100644 apps/preview-server/server.mjs create mode 100644 tests/integration/preview-server.test.ts diff --git a/apps/preview-server/package.json b/apps/preview-server/package.json new file mode 100644 index 0000000..4f3bf88 --- /dev/null +++ b/apps/preview-server/package.json @@ -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" + } +} diff --git a/apps/preview-server/server.d.mts b/apps/preview-server/server.d.mts new file mode 100644 index 0000000..95e4651 --- /dev/null +++ b/apps/preview-server/server.d.mts @@ -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 { + /** 构建产物目录(/jobs//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 + /** 关停(destroy 连接后 close——防 keep-alive 套接字吊住) */ + stop(): Promise +} + +export declare function createPreviewServer(options: PreviewServerOptions): PreviewServerHandle diff --git a/apps/preview-server/server.mjs b/apps/preview-server/server.mjs new file mode 100644 index 0000000..c6e5835 --- /dev/null +++ b/apps/preview-server/server.mjs @@ -0,0 +1,317 @@ +// 本地预览服务器(Plan 4 Task 7,V2 spec §12.2):路由驱动的静态 dist 服务—— +// 只读 /jobs//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 `${title}

${title}

${bodyText}

\n` +} + +const GONE_HTML = htmlDocument('410 Gone', '此内容已被永久移除(route-manifest kind=gone)。') + +/** + * 创建预览服务器(不监听——start() 才绑定)。返回 {server, start, stop}: + * start() → Promise(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', `本页已迁移:${location}`), 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 --manifest [--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 --manifest [--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 + }) +} diff --git a/eslint.config.js b/eslint.config.js index 15a4d6c..cddc9b4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', }, }, }, diff --git a/scripts/sf.ts b/scripts/sf.ts index 0d44937..8411765 100644 --- a/scripts/sf.ts +++ b/scripts/sf.ts @@ -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 ', '构建 job id(须 succeeded——dist 产物只在成功终态存在)') + .option('--port ', '监听端口(0 = 随机可用端口)', '4173') + .option('--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 } }).$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) => { diff --git a/tests/integration/preview-server.test.ts b/tests/integration/preview-server.test.ts new file mode 100644 index 0000000..89ed5dc --- /dev/null +++ b/tests/integration/preview-server.test.ts @@ -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' + +/** 本文件位于 /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 { + 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 { + 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; 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 } }).$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((resolveP) => { + child.once('exit', () => resolveP()) + setTimeout(() => { + child.kill('SIGKILL') + resolveP() + }, 10_000).unref() + }) + } + }) +})