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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||
}
|
||||
|
||||
/** 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user