feat(build): astro 构建管线与媒体落盘

Plan 4 Task 5:build.service snapshotting 后接真实构建——building 态进
pipeline.ts(spawn `pnpm --filter @sf/theme-pilot-minimal exec astro build`
(SF_BUILD_INPUT env;非零退出+stderr 标记判据)→ dist 定位与 target_file
完整性 → 媒体 copyPath→dist/public_path(sha256 全量校验)→ sitemap.xml/
robots.txt(编码 URL 原样)→ route-manifest.json(§12.1 字段面、哈希链复用)
→ build_artifacts dist/manifest 登记)→ validating(空,Task 6) → 报告+report
行 → succeeded;失败任何一步 failed+error_code/error_summary(stderr 摘要)
+input/dist 清残。

dist 双位置纪律:主题包 dist 是暂存,产物汇齐后拷出 <artifactsRoot>/jobs/
<jobId>/dist(job 级权威),暂存随即清空。无网证明:ASTRO_TELEMETRY_DISABLED=1
(实测 @astrojs/telemetry 构建期真调 fetch)+ NODE_OPTIONS 透传(测试注入
fetch/net stub 构建仍成功)。主题集成补面:ProductList 模板 + /products/
主列表分派特判(DB 物化形状 targetContentId=null,Task 4 夹具曾被占位绕过)。

测试:tests/integration/build-pipeline.test.ts 五用例(全链/夹具直跑/五禁
断言/失败路径);build-snapshot 状态机补 building + 清场补 build_artifacts。
root 359 + theme 26 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 10:52:13 +08:00
parent 107642c1b3
commit 1ed3ac062a
8 changed files with 1200 additions and 30 deletions
+90 -22
View File
@@ -1,10 +1,11 @@
// build_jobs 生命周期服务(Plan 4 Task 2,V2 spec §9、ADR-010/ADR-011):
// build_jobs 生命周期服务(Plan 4 Task 2/5,V2 spec §9、ADR-010/ADR-011):
// 解析站点 → publish 状态检查(快照只读 published——空发布集无物化意义)→ 幂等建
// job(queued)→ snapshotting(render-snapshot.ts 纯物化六文件 + build_input_snapshots
// 行,repeatable read 单事务一致性读取)→ validating(**空门禁**——Task 6 填真实
// 门禁;Task 5 接真实构建时**新建 job 不改行**:build_input_snapshots 有不可变触发器)
// → succeeded;报告落 <artifactsRoot>/jobs/<jobId>/reports/build.json(temp+rename
// 原子写、**先于终态落行**——import 同款纪律)。
// 行,repeatable read 单事务一致性读取)→ **building(Task 5:pipeline.ts 真实构建
// ——spawn astro → 媒体复制 → sitemap/robots → route-manifest → build_artifacts
// dist/manifest 登记)**→ validating(**空门禁**——Task 6 填真实门禁)→ succeeded;
// 报告落 <artifactsRoot>/jobs/<jobId>/reports/build.json + build_artifacts report 行
// (temp+rename 原子写、**先于终态落行**——import 同款纪律)。
//
// 幂等:build_jobs.idempotency_key UNIQUE——重复提交同 key:**只有 succeeded 复用**
// (产物完整);failed/cancelled/stale 终态死行与 queued/snapshotting/building/
@@ -33,9 +34,17 @@ import { fileURLToPath } from 'node:url'
import { desc, eq } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import { canonicalJson } from '../../common/canonical.js'
import { buildInputSnapshots, buildJobs, entryLocales, siteConfigRevisions, sites } from '../../db/schema.js'
import {
buildArtifacts,
buildInputSnapshots,
buildJobs,
entryLocales,
siteConfigRevisions,
sites,
} from '../../db/schema.js'
import type * as schema from '../../db/schema.js'
import { isUniqueViolation } from '../source/register.service.js'
import { PipelineError, runBuildPipeline, themeDistDir, type BuildPipelineResult } from './pipeline.js'
import { materializeRenderSnapshot, type RewriteStats } from './render-snapshot.js'
/** 本文件位于 <repo>/apps/api/src/modules/build/ → 上五级是仓库根 */
@@ -51,6 +60,7 @@ export type BuildErrorCode =
| 'NO_SITE_CONFIG'
| 'SNAPSHOT_FAILED'
| 'REPORT_WRITE_FAILED'
| 'ARTIFACT_REGISTER_FAILED'
/** 构建失败(可预期错误):CLI 以 code 区分呈现;不裹程序 bug(那些原样上抛) */
export class BuildError extends Error {
@@ -106,6 +116,8 @@ export interface CreateBuildJobResult {
counts?: { contents: number; taxonomies: number; routes: number; assets: number }
urlRewrite?: RewriteStats
issues?: string[]
/** Task 5 产物面(dist/route-manifest 路径与哈希 + 计数)——复用路径无值(产物在原 job 报告) */
artifacts?: BuildPipelineResult
durationMs: number
}
@@ -135,6 +147,16 @@ export interface BuildReport {
}
files: Array<{ name: string; path: string; sha256: string; byteSize: number }>
urlRewrite: RewriteStats
/** Task 5 产物面(与 build_artifacts 行互证) */
artifacts: {
distPath: string
distSha256: string
distFileCount: number
routeManifestPath: string
routeManifestSha256: string
sitemapEntries: number
mediaCopied: number
}
issues: string[]
durationMs: number
}
@@ -227,9 +249,11 @@ const RETRY_KEY_BOUND = 100
* 解析(site → published 检查 → config 检查)→ 幂等建行(queued;**只有 succeeded
* 可复用**——终态死行/崩溃残留按阶段 3 注记的策略自动恢复)→ snapshotting
* (repeatable read 单事务:物化 + 六文件落盘 + build_input_snapshots 行)→
* validating(空——Task 6)→ 报告落盘 → succeeded。
* dist 产物与 build_artifacts 登记是 Task 5 职责(本任务 dist 尚空)。
* 快照后失败 = job 行 failed 终态 + 半途 input/ 目录清场 + 原样上抛。
* building(pipeline.ts:spawn astro → 媒体复制 → sitemap/robots → route-manifest
* → build_artifacts dist/manifest 两行)→ validating(空——Task 6)→ 报告落盘 +
* build_artifacts report 行 → succeeded。
* 任一阶段失败 = job 行 failed 终态(PipelineError.code 进 error_code、stderr 标记进
* error_summary)+ 半途产物清场(input/ 与 dist/——失败无产物语义)+ 原样上抛。
*/
export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): Promise<CreateBuildJobResult> {
const startedAt = performance.now()
@@ -387,11 +411,23 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
{ isolationLevel: 'repeatable read' },
)
// 阶段 6:validating(**空门禁**——Task 6 填真实门禁;状态机占位完整)
// 阶段 6:building——真实构建(Task 5 pipeline.ts:spawn/媒体/sitemap/manifest/
// build_artifacts dist+manifest 登记)。失败以 PipelineError 上抛(code 进 error_code,
// stderr 标记进 error_summary——收口见下方 catch)。
await db.update(buildJobs).set({ status: 'building' }).where(eq(buildJobs.id, jobId))
input.onStatus?.('building', jobId)
const built = await runBuildPipeline(db, {
jobId: jobId!,
siteId: site.id,
inputDir,
artifactsRoot,
})
// 阶段 7:validating(**空门禁**——Task 6 填真实门禁;状态机占位完整)
await db.update(buildJobs).set({ status: 'validating' }).where(eq(buildJobs.id, jobId))
input.onStatus?.('validating', jobId)
// 阶段 7:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在
// 阶段 8:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在
const report: BuildReport = {
jobId,
generatedAt: buildTimestamp.toISOString(),
@@ -425,12 +461,22 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
byteSize: Buffer.byteLength(content, 'utf8'),
})),
urlRewrite: mat.urlRewrite,
artifacts: {
distPath: built.distPath,
distSha256: built.distSha256,
distFileCount: built.distFileCount,
routeManifestPath: built.routeManifestPath,
routeManifestSha256: built.routeManifestSha256,
sitemapEntries: built.sitemapEntries,
mediaCopied: built.mediaCopied,
},
issues: mat.issues,
durationMs: performance.now() - startedAt,
}
const reportText = `${JSON.stringify(report, null, 2)}\n`
try {
await mkdir(reportDir, { recursive: true })
await writeAtomically(reportPath, `${JSON.stringify(report, null, 2)}\n`)
await writeAtomically(reportPath, reportText)
} catch (err) {
throw new BuildError(
'REPORT_WRITE_FAILED',
@@ -438,7 +484,23 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
)
}
// 阶段 8:succeeded 终态
// 阶段 9:report 行登记(build_artifacts 第三行——报告文件已落盘,行内即文件在)
try {
await db.insert(buildArtifacts).values({
buildJobId: jobId,
kind: 'report',
storagePath: reportPath,
sha256: createHash('sha256').update(reportText, 'utf8').digest('hex'),
byteSize: Buffer.byteLength(reportText, 'utf8'),
})
} catch (err) {
throw new BuildError(
'ARTIFACT_REGISTER_FAILED',
`report 产物登记失败(job=${jobId}):${err instanceof Error ? err.message : String(err)}`,
)
}
// 阶段 10:succeeded 终态
await db.update(buildJobs).set({ status: 'succeeded', finishedAt: new Date() }).where(eq(buildJobs.id, jobId))
input.onStatus?.('succeeded', jobId)
@@ -454,23 +516,29 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
counts: mat.counts,
urlRewrite: mat.urlRewrite,
issues: mat.issues,
artifacts: built,
durationMs: performance.now() - startedAt,
}
} catch (err) {
const code = err instanceof BuildError ? err.code : 'SNAPSHOT_FAILED'
// 阶段敏感的错误码映射:BuildError 保自身码(解析/报告/登记);PipelineError 带
// 管线阶段码(spawn/媒体/manifest——stderr 标记在 message 内一并入 error_summary);
// 其余(快照事务/物化)沿用 SNAPSHOT_FAILED 总括码。
const code = err instanceof BuildError ? err.code : err instanceof PipelineError ? err.code : 'SNAPSHOT_FAILED'
const summary = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
await db
.update(buildJobs)
.set({ status: 'failed', finishedAt: new Date(), errorCode: code, errorSummary: `[build] ${summary}` })
.where(eq(buildJobs.id, jobId))
// 失败清场(审查 Fix 3):半途落盘的 input/ 目录移除——failed 终态无产物语义,
// 不留"行说 failed 而目录半存"的歧义现场。快照行若已插入则由不可变触发器保留为
// 审计记录(manifest_path 悬空可接受:job 已 failed,无下游消费方)。清场失败
// 不掩盖原始错误(残留目录由操作者处置)。
try {
await rm(inputDir, { recursive: true, force: true })
} catch {
// best-effort:保留原错误上抛
// 失败清场(审查 Fix 3 + Task 5 扩面):半途落盘的 input/ 与 dist/(job 级产物目录
// + 主题包暂存 dist)移除——failed 终态无产物语义,不留"行说 failed 而目录半存"
// 的歧义现场。快照行若已插入则由不可变触发器保留为审计记录(manifest_path 悬空
// 可接受:job 已 failed,无下游消费方)。清场失败不掩盖原始错误(残留目录由操作者处置)。
for (const dir of [inputDir, join(artifactsRoot, 'jobs', jobId, 'dist'), themeDistDir()]) {
try {
await rm(dir, { recursive: true, force: true })
} catch {
// best-effort:保留原错误上抛
}
}
throw err
}
+470
View File
@@ -0,0 +1,470 @@
// 构建编排管线(Plan 4 Task 5,V2 spec §9.3/§12.1、快线 §8.4):
// spawn astro 构建(SF_BUILD_INPUT 六文件输入)→ dist 定位(主题包 outDir)→
// 媒体复制(copyPath→dist/public_path,sha256 全量校验失配即败)→ sitemap.xml/
// robots.txt(origin 来自 site.json)→ route-manifest.json(§12.1 字段面,哈希链复用
// render-manifest 的 route 快照哈希)→ build_artifacts 登记(dist/manifest 两行;
// report 行是 build.service 落报告后的职责)。
//
// 本文件是"纯编排段":不做 job 状态机推进(building→validating→succeeded 与失败收口
// 都在 build.service.ts),失败以 PipelineError 上抛(code 区分阶段,message 带
// stderr 摘要——Windows 下 pnpm 对子进程失败会叠 127/EPERM/libuv 噪音,判据是
// **非零退出 + stderr 标记**,不断言精确退出码)。
//
// dist 双位置纪律:主题包内 dist(astro outDir)是**暂存**——spawn 产物 + 媒体 +
// sitemap/robots 在此汇齐后整体拷出到 <artifactsRoot>/jobs/<jobId>/dist(job 级
// 权威产物,行内即文件在),暂存随即清空(成功失败都清——不留跨 job 共享可变态)。
// 跨盘拷贝用 copy 不用 rename(artifacts 根常在另一卷,rename 撞 EXDEV)。
//
// 无网纪律(五禁③的实现侧配合):spawn env 显式 ASTRO_TELEMETRY_DISABLED=1——
// @astrojs/telemetry 在构建期真调 global fetch POST(实测撞无网 stub);离线构建
// 纪律下遥测必须关。NODE_OPTIONS 原样透传(spread 继承 + 本管线不自加标志的合并
// 语义):测试可注入 `--import <stub>` 预载钩子证明构建进程树不联网。
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import {
ROUTE_MANIFEST_VERSION,
type AssetEntry,
type RouteEntry,
type RouteManifest,
type RouteManifestRoute,
} from '@sf/contracts'
import { buildArtifacts } from '../../db/schema.js'
import type * as schema from '../../db/schema.js'
/** 本文件位于 <repo>/apps/api/src/modules/build/ → 上五级是仓库根 */
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..')
/** Plan 4 唯一主题:包全名 filter(Task 3 备忘——勿用目录名短 filter) */
const THEME_PACKAGE = '@sf/theme-pilot-minimal'
const THEME_DIR = join(REPO_ROOT, 'themes', 'pilot-minimal')
/** 主题包内暂存 dist(astro outDir='./dist')——产物汇齐后拷出、随即清空 */
export function themeDistDir(): string {
return join(THEME_DIR, 'dist')
}
export type PipelineDb = NodePgDatabase<typeof schema>
export type PipelineErrorCode = 'INPUT_INVALID' | 'THEME_BUILD_FAILED' | 'MEDIA_COPY_FAILED' | 'MANIFEST_WRITE_FAILED'
/** 管线阶段失败(可预期错误):code 区分阶段;stderr 摘要进 message(落 error_summary) */
export class PipelineError extends Error {
readonly code: PipelineErrorCode
constructor(code: PipelineErrorCode, message: string) {
super(message)
this.name = 'PipelineError'
this.code = code
}
}
export interface RunBuildPipelineInput {
jobId: string
siteId: string
/** 六文件输入目录(SF_BUILD_INPUT——build_input_snapshots 物化产物) */
inputDir: string
/** 产物根:<root>/jobs/<jobId>/{input,dist,reports,route-manifest.json} */
artifactsRoot: string
}
export interface BuildPipelineResult {
distPath: string
/** 目录哈希(公式见 hashDirectory——递归文件清单 sha256) */
distSha256: string
distByteSize: number
distFileCount: number
routeManifestPath: string
routeManifestSha256: string
routeManifestByteSize: number
sitemapEntries: number
mediaCopied: number
}
// ---- 输入读取 ----
async function readJsonInput(inputDir: string, file: string): Promise<unknown> {
let text: string
try {
text = await readFile(join(inputDir, file), 'utf8')
} catch (err) {
throw new PipelineError(
'INPUT_INVALID',
`管线输入缺失或不可读:${join(inputDir, file)}(${err instanceof Error ? err.message : String(err)})`,
)
}
try {
return JSON.parse(text)
} catch (err) {
throw new PipelineError(
'INPUT_INVALID',
`管线输入不是合法 JSON:${file}(${err instanceof Error ? err.message : String(err)})`,
)
}
}
// ---- spawn ----
/** spawn 超时上界(含 pnpm 解析与 astro 冷启——Windows 实测全链 ~10s,宽限到分钟级) */
const SPAWN_TIMEOUT_MS = 300_000
/** stdout/stderr 各自的累积上界(超出丢弃并标记截断——防失控输出撑爆内存) */
const STREAM_CAP = 2 * 1024 * 1024
function spawnEnv(inputDir: string): NodeJS.ProcessEnv {
// NODE_OPTIONS 合并语义:spread 继承调用方(含测试注入的 --import 预载),本管线
// 不追加不清洗;自己的覆盖只有 SF_BUILD_INPUT 与遥测关闭(见模块头无网纪律注记)。
return {
...process.env,
SF_BUILD_INPUT: inputDir,
ASTRO_TELEMETRY_DISABLED: '1',
}
}
/** astro 构建 spawn:cwd=仓库根(pnpm workspace 解析),退出非零即败(stderr 摘要入 message) */
function spawnAstroBuild(inputDir: string): Promise<void> {
return new Promise<void>((resolvePromise, rejectPromise) => {
let settled = false
let stderr = ''
let stderrTruncated = false
const child = spawn('pnpm', ['--filter', THEME_PACKAGE, 'exec', 'astro', 'build'], {
cwd: REPO_ROOT,
shell: true, // Windows:pnpm 是 pnpm.cmd,须经 shell 解析(参数均为无空格字面量)
env: spawnEnv(inputDir),
stdio: ['ignore', 'pipe', 'pipe'],
})
const timer = setTimeout(() => {
child.kill('SIGKILL')
if (!settled) {
settled = true
rejectPromise(
new PipelineError('THEME_BUILD_FAILED', `astro 构建超时(>${SPAWN_TIMEOUT_MS}ms)——SIGKILL 终结`),
)
}
}, SPAWN_TIMEOUT_MS)
child.stderr?.on('data', (chunk: Buffer) => {
if (stderr.length >= STREAM_CAP) {
stderrTruncated = true
return
}
stderr += chunk.toString('utf8')
if (stderr.length > STREAM_CAP) {
stderr = stderr.slice(0, STREAM_CAP)
stderrTruncated = true
}
})
// stdout 只排水不累积(页面清单对诊断无增量价值;异常路径的信号在 stderr)
child.stdout?.on('data', () => {})
child.on('error', (err) => {
clearTimeout(timer)
if (!settled) {
settled = true
rejectPromise(new PipelineError('THEME_BUILD_FAILED', `astro 构建 spawn 失败:${err.message}`))
}
})
child.on('close', (code, signal) => {
clearTimeout(timer)
if (settled) return
settled = true
if (code === 0) {
resolvePromise()
return
}
// 判据 = 非零退出 + stderr 标记(Windows pnpm 会叠 127/libuv 噪音——勿匹配精确码)。
// 摘要取"头 500 + 尾 4000":astro 的错误块(消息+栈)靠 stderr 尾部,但纯尾截会把
// 标记行拦腰截断(消息在错误块头部)——头部保上下文,尾部保完整错误块。
const head = stderr.length > 4500 ? `${stderr.slice(0, 500)}\n…(中段省略)\n` : ''
const tail = stderr.length > 4000 ? stderr.slice(-4000) : stderr
const excerpt = `${head}${tail}`
const truncNote = stderrTruncated ? '(stderr 超 2MB 已截断)' : ''
rejectPromise(
new PipelineError(
'THEME_BUILD_FAILED',
`astro 构建失败(exit=${String(code)} signal=${String(signal)})${truncNote}——stderr 摘要:\n${excerpt}`,
),
)
})
})
}
// ---- dist 后处理 ----
/**
* 路由 → dist 相对产物路径(映射规则唯一权威——route-writer 的 html target_file 落 NULL
* 即"由构建器落地";与主题侧 sf-literal-html-paths 折平集成对齐):
* '/' → index.html;尾斜杠 → <path>/index.html;.html 后缀 → 字面文件。
*/
export function deriveTargetFile(routePath: string): string {
if (routePath === '/') return 'index.html'
const rel = routePath.replace(/^\//, '').replace(/\/+$/, '')
return rel.endsWith('.html') ? rel : `${rel}/index.html`
}
/** temp+rename 原子写(与 build.service 同款纪律——不留半成品) */
async function writeAtomically(path: string, content: string): Promise<void> {
const tmpPath = `${path}.tmp`
try {
await writeFile(tmpPath, content, 'utf8')
await rename(tmpPath, path)
} catch (err) {
await rm(tmpPath, { force: true })
throw err
}
}
/** 媒体复制:copyPath→dist/public_path;**每条**落盘后 sha256 全量校验(非抽检)失配即败 */
async function copyMedia(distDir: string, assets: AssetEntry[]): Promise<number> {
for (const a of assets) {
// public_path 必须是根相对安全路径(media-writer 上游已按此登记——此处守越界写)
if (!a.publicPath.startsWith('/') || a.publicPath.split('/').includes('..')) {
throw new PipelineError(
'MEDIA_COPY_FAILED',
`媒体 public_path 非根相对安全路径(${a.publicPath})——拒绝越界落盘`,
)
}
const src = resolve(REPO_ROOT, a.copyPath)
const dst = join(distDir, a.publicPath)
try {
await mkdir(dirname(dst), { recursive: true })
await copyFile(src, dst)
} catch (err) {
throw new PipelineError(
'MEDIA_COPY_FAILED',
`媒体复制失败(${a.publicPath} ← ${a.copyPath}):${err instanceof Error ? err.message : String(err)}`,
)
}
const copied = createHash('sha256').update(await readFile(dst)).digest('hex')
if (copied !== a.sha256) {
throw new PipelineError(
'MEDIA_COPY_FAILED',
`媒体 sha256 失配(${a.publicPath}):assets.json=${a.sha256} ≠ 落盘=${copied}——输入侧副本与清单破裂`,
)
}
}
return assets.length
}
/** XML 属性/文本转义(编码 URL 原样贯穿的边界:%xx 不受影响,裸 &<>" 转义) */
function xmlEscape(s: string): string {
return s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}
/** UTF-16 字典序(与物化端排序同语义——钉死不依赖 locale) */
function cmp(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
function cmpNull(a: string | null, b: string | null): number {
if (a === null && b === null) return 0
if (a === null) return -1
if (b === null) return 1
return cmp(a, b)
}
function buildSitemap(origin: string, routes: RouteEntry[]): { xml: string; entries: number } {
const rows = routes
.filter((r) => r.routeKind === 'html' && r.indexable)
.sort((a, b) => cmp(a.routePath, b.routePath) || cmpNull(a.queryKey, b.queryKey))
const items = rows.map((r) => {
const url = r.queryKey === null ? `${origin}${r.routePath}` : `${origin}${r.routePath}?${r.queryKey}`
return ` <url><loc>${xmlEscape(url)}</loc></url>`
})
return {
xml: `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${items.join('\n')}\n</urlset>\n`,
entries: rows.length,
}
}
function buildRobots(origin: string): string {
return `User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`
}
/** route-manifest 行映射:exact_key 带查询形态;target_file 派生映射;redirect 带 location */
function toManifestRoute(r: RouteEntry, assetsByPath: Map<string, AssetEntry>): RouteManifestRoute {
// redirect 行 redirectStatus 是权威(targetStatus 同值兜底——数据面两处冗余)
const rawStatus = r.routeKind === 'redirect' ? (r.redirectStatus ?? Number(r.targetStatus)) : Number(r.targetStatus)
if (!Number.isInteger(rawStatus) || rawStatus < 100 || rawStatus > 599) {
throw new PipelineError(
'MANIFEST_WRITE_FAILED',
`路由 ${r.routePath} 的 HTTP 状态非法(${String(rawStatus)})——target_status/redirect_status 数据面破裂`,
)
}
const row: RouteManifestRoute = {
exact_key: r.queryKey === null ? r.routePath : `${r.routePath}?${r.queryKey}`,
query_policy: r.queryPolicy,
query_key: r.queryKey,
kind: r.routeKind,
status: rawStatus,
indexable: r.indexable,
}
if (r.routeKind === 'html') {
row.target_file = deriveTargetFile(r.routePath)
row.content_type = 'text/html'
} else if (r.routeKind === 'asset') {
row.target_file = r.targetFile ?? r.routePath
const asset = assetsByPath.get(r.routePath)
if (asset !== undefined) row.content_type = asset.detectedMimeType
} else if (r.routeKind === 'redirect') {
if (r.redirectLocation !== null) row.location = r.redirectLocation
}
return row
}
/**
* 目录哈希(build_artifacts dist 行的 sha256 口径):递归文件清单(posix 相对路径
* 字典序)每行 `rel\truntime sha256`,清单文本(含尾换行)的 sha256——文件集合与
* 内容任一变化即变,操作者可离线复算互证。byteSize = 递归文件字节和。
*/
async function hashDirectory(dir: string): Promise<{ sha256: string; byteSize: number; fileCount: number }> {
const rels: string[] = []
const walk = async (d: string): Promise<void> => {
for (const e of await readdir(d, { withFileTypes: true })) {
const full = join(d, e.name)
if (e.isDirectory()) await walk(full)
else rels.push(relative(dir, full).replaceAll('\\', '/'))
}
}
await walk(dir)
rels.sort()
let byteSize = 0
const lines: string[] = []
for (const rel of rels) {
const buf = await readFile(join(dir, rel))
byteSize += buf.byteLength
lines.push(`${rel}\t${createHash('sha256').update(buf).digest('hex')}`)
}
return { sha256: createHash('sha256').update(`${lines.join('\n')}\n`).digest('hex'), byteSize, fileCount: rels.length }
}
async function copyTree(src: string, dst: string): Promise<void> {
await mkdir(dst, { recursive: true })
for (const e of await readdir(src, { withFileTypes: true })) {
if (e.isDirectory()) await copyTree(join(src, e.name), join(dst, e.name))
else await copyFile(join(src, e.name), join(dst, e.name))
}
}
/**
* 构建管线主体(编排顺序见模块头)。前置不变量:inputDir 六文件齐备(service 物化
* 已保证;直调方破坏不变量时以 INPUT_INVALID 拒绝,不产残缺产物)。
* build_artifacts 登记 dist/manifest 两行;report 行由 build.service 落报告后补登。
*/
export async function runBuildPipeline(db: PipelineDb, input: RunBuildPipelineInput): Promise<BuildPipelineResult> {
const routes = (await readJsonInput(input.inputDir, 'routes.json')) as RouteEntry[]
const assets = (await readJsonInput(input.inputDir, 'assets.json')) as AssetEntry[]
const siteRaw = (await readJsonInput(input.inputDir, 'site.json')) as { origin?: unknown }
const inputManifest = (await readJsonInput(input.inputDir, 'render-manifest.json')) as {
sourceHashes?: { route?: unknown }
}
if (!Array.isArray(routes) || routes.length === 0) {
throw new PipelineError('INPUT_INVALID', 'routes.json 为空或非数组——正常发布站点至少含首页路由')
}
if (!Array.isArray(assets)) {
throw new PipelineError('INPUT_INVALID', 'assets.json 非数组——物化面破裂')
}
if (typeof siteRaw.origin !== 'string' || siteRaw.origin === '') {
throw new PipelineError('INPUT_INVALID', 'site.json origin 缺席/为空——sitemap 与 manifest 的 origin 无来源')
}
const origin = siteRaw.origin
const routeHash =
typeof inputManifest.sourceHashes?.route === 'string' ? inputManifest.sourceHashes.route : ''
if (routeHash === '') {
throw new PipelineError('INPUT_INVALID', 'render-manifest.json sourceHashes.route 缺席——哈希链断链')
}
// ① spawn(主题包 dist 暂存先行清空——跨 build 残留归零,astro 自身清 outDir 之外双保险)
const scratchDist = themeDistDir()
await rm(scratchDist, { recursive: true, force: true })
await spawnAstroBuild(input.inputDir)
// ② dist 定位与完整性:每条 html 行 target_file(派生映射)在暂存 dist 存在且是文件
// (主题侧折平集成已对齐映射——此处失配即主题/管线映射破裂,禁静默放行)
const scratchStat = await stat(scratchDist).catch(() => null)
if (scratchStat === null || !scratchStat.isDirectory()) {
throw new PipelineError('THEME_BUILD_FAILED', `主题构建产物目录缺席:${scratchDist}`)
}
for (const r of routes) {
if (r.routeKind !== 'html') continue
const rel = deriveTargetFile(r.routePath)
const st = await stat(join(scratchDist, rel)).catch(() => null)
if (st === null || !st.isFile()) {
throw new PipelineError(
'THEME_BUILD_FAILED',
`dist 产物缺失(${r.routePath} → ${rel})——主题产物与路由表映射破裂(禁静默放行)`,
)
}
}
// ③ 媒体复制(sha256 全量校验)④ sitemap/robots(写入暂存 dist——它们是站点服务面文件)
const mediaCopied = await copyMedia(scratchDist, assets)
const sitemap = buildSitemap(origin, routes)
await writeAtomically(join(scratchDist, 'sitemap.xml'), sitemap.xml)
await writeAtomically(join(scratchDist, 'robots.txt'), buildRobots(origin))
// ⑤ 暂存拷出到 job 级权威产物目录,暂存清空(见模块头双位置纪律)
const distPath = join(input.artifactsRoot, 'jobs', input.jobId, 'dist')
await rm(distPath, { recursive: true, force: true })
await copyTree(scratchDist, distPath)
await rm(scratchDist, { recursive: true, force: true })
// ⑥ route-manifest(§12.1:物化哈希链复用 + 行序 = routes.json 物化序)
const assetsByPath = new Map(assets.map((a) => [a.publicPath, a]))
const manifest: RouteManifest = {
manifest_version: ROUTE_MANIFEST_VERSION,
site_id: input.siteId,
build_job_id: input.jobId,
origin,
generated_from_route_snapshot_hash: routeHash,
routes: routes.map((r) => toManifestRoute(r, assetsByPath)),
}
const manifestText = `${JSON.stringify(manifest, null, 2)}\n`
const manifestPath = join(input.artifactsRoot, 'jobs', input.jobId, 'route-manifest.json')
try {
await mkdir(dirname(manifestPath), { recursive: true })
await writeAtomically(manifestPath, manifestText)
} catch (err) {
throw new PipelineError(
'MANIFEST_WRITE_FAILED',
`route-manifest 落盘失败(${manifestPath}):${err instanceof Error ? err.message : String(err)}`,
)
}
// ⑦ build_artifacts 登记(dist/manifest 两行;UNIQUE(build_job_id,kind,storage_path))
const dirHash = await hashDirectory(distPath)
const manifestSha256 = createHash('sha256').update(manifestText, 'utf8').digest('hex')
try {
await db.insert(buildArtifacts).values([
{
buildJobId: input.jobId,
kind: 'dist',
storagePath: distPath,
sha256: dirHash.sha256,
byteSize: dirHash.byteSize,
},
{
buildJobId: input.jobId,
kind: 'manifest',
storagePath: manifestPath,
sha256: manifestSha256,
byteSize: Buffer.byteLength(manifestText, 'utf8'),
},
])
} catch (err) {
throw new PipelineError(
'MANIFEST_WRITE_FAILED',
`build_artifacts 登记失败(job=${input.jobId}):${err instanceof Error ? err.message : String(err)}`,
)
}
return {
distPath,
distSha256: dirHash.sha256,
distByteSize: dirHash.byteSize,
distFileCount: dirHash.fileCount,
routeManifestPath: manifestPath,
routeManifestSha256: manifestSha256,
routeManifestByteSize: Buffer.byteLength(manifestText, 'utf8'),
sitemapEntries: sitemap.entries,
mediaCopied,
}
}