Files
jiapuapp/design-pipeline/scripts/runtime-asset-inventory.mjs

152 lines
6.5 KiB
JavaScript

import { createHash } from 'node:crypto'
import { readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
const formalManifestKinds = new Set(['runtime-asset-inventory'])
const assertOnlyFields = (value, fields, label) => {
for (const field of Object.keys(value)) {
if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`)
}
}
const resolveInsideWorkspace = (workspace, candidate, label) => {
if (typeof candidate !== 'string' || candidate.trim() === '') {
throw new Error(`${label} must be a non-empty string`)
}
const absolutePath = path.resolve(workspace, candidate)
const relative = path.relative(workspace, absolutePath)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`${label} escapes workspace: ${candidate}`)
}
return absolutePath
}
const readManifest = async (manifestPath) => {
try {
return JSON.parse(await readFile(manifestPath, 'utf8'))
} catch (error) {
if (error.code === 'ENOENT') throw new Error(`missing manifest: ${manifestPath}`)
throw error
}
}
const validateDirectAsset = (asset, workspace) => {
if (!asset || typeof asset !== 'object' || Array.isArray(asset)) throw new Error('runtime asset must be an object')
assertOnlyFields(
asset,
new Set(['id', 'output', 'width', 'height', 'alpha', 'bytes', 'sha256', 'provenance', 'rebuildable']),
'runtime asset',
)
for (const key of ['id', 'output']) {
if (typeof asset[key] !== 'string' || asset[key].trim() === '') {
throw new Error(`runtime asset ${key} must be a non-empty string`)
}
}
resolveInsideWorkspace(workspace, asset.output, `${asset.id}.output`)
for (const key of ['width', 'height', 'bytes']) {
if (!Number.isInteger(asset[key]) || asset[key] <= 0) throw new Error(`${asset.id}.${key} must be a positive integer`)
}
if (typeof asset.alpha !== 'boolean') throw new Error(`${asset.id}.alpha must be boolean`)
if (asset.provenance !== 'committed-binary') {
throw new Error(`${asset.id}.provenance must be committed-binary`)
}
if (asset.rebuildable !== false) throw new Error(`${asset.id}.rebuildable must be false`)
if (!/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error(`${asset.id}.sha256 must be lowercase SHA256`)
return asset
}
export const expandRuntimeAssetInventory = async (rootManifestPath, workspace) => {
const workspacePath = path.resolve(workspace)
const loaded = new Set()
const stack = []
const ids = new Map()
const outputs = new Map()
const manifests = []
const addAsset = (asset, owner) => {
if (ids.has(asset.id)) {
throw new Error(`duplicate asset id: ${asset.id} (${ids.get(asset.id)}, ${owner})`)
}
if (outputs.has(asset.output)) {
throw new Error(`duplicate output: ${asset.output} (${outputs.get(asset.output).owner}, ${owner})`)
}
ids.set(asset.id, owner)
outputs.set(asset.output, { ...asset, owner })
}
// 递归展开只处理机器清单之间的依赖;页面消费者始终从源码扫描得到,避免双写。
const visit = async (manifestPath) => {
const absolutePath = resolveInsideWorkspace(workspacePath, manifestPath, 'manifest')
if (stack.includes(absolutePath)) {
throw new Error(`import cycle: ${[...stack, absolutePath].join(' -> ')}`)
}
if (loaded.has(absolutePath)) throw new Error(`duplicate import: ${absolutePath}`)
stack.push(absolutePath)
loaded.add(absolutePath)
manifests.push(absolutePath)
const manifest = await readManifest(absolutePath)
if (manifest.kind === 'runtime-asset-inventory') {
assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'scope', 'imports', 'assets']), 'runtime manifest')
if (manifest.schemaVersion !== 1) throw new Error('runtime inventory schemaVersion must be 1')
if (typeof manifest.scope !== 'string' || manifest.scope.trim() === '') throw new Error('runtime inventory scope is required')
if (!Array.isArray(manifest.imports) || !Array.isArray(manifest.assets)) {
throw new Error('runtime inventory imports and assets must be arrays')
}
for (const asset of manifest.assets) addAsset(validateDirectAsset(asset, workspacePath), absolutePath)
for (const imported of manifest.imports) await visit(imported)
} else {
throw new Error(`unsupported manifest kind: ${manifest.kind}`)
}
stack.pop()
}
await visit(path.relative(workspacePath, path.resolve(rootManifestPath)))
return { manifests, assets: [...outputs.values()] }
}
export const validateRuntimeAssetRegistry = async (rootManifestPath, workspace, manifestsDirectory) => {
const workspacePath = path.resolve(workspace)
const directory = resolveInsideWorkspace(workspacePath, manifestsDirectory, 'manifests directory')
const rootManifest = await readManifest(path.resolve(rootManifestPath))
if (rootManifest.kind !== 'runtime-asset-inventory' || rootManifest.scope !== 'schema-v3') {
throw new Error('registry root scope must be schema-v3')
}
const inventory = await expandRuntimeAssetInventory(rootManifestPath, workspacePath)
const registered = new Set(inventory.manifests.map((manifest) => path.resolve(manifest)))
for (const asset of inventory.assets) {
let binary
try {
binary = await readFile(resolveInsideWorkspace(workspacePath, asset.output, `${asset.id}.output`))
} catch (error) {
if (error.code === 'ENOENT') throw new Error(`missing runtime asset: ${asset.output}`)
throw error
}
if (binary.length !== asset.bytes) {
throw new Error(`${asset.id}.bytes does not match ${asset.output}`)
}
const digest = createHash('sha256').update(binary).digest('hex')
if (digest !== asset.sha256) {
throw new Error(`${asset.id}.sha256 does not match ${asset.output}`)
}
}
// 顶层注册表必须覆盖目录内每一份正式 owner。这样新增清单若没有接入全局图会立即失败,
// output 与 id 的唯一性也就不再局限于某个业务域的 imports 闭包。
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== '.json') continue
const manifestPath = path.join(directory, entry.name)
const manifest = await readManifest(manifestPath)
if (formalManifestKinds.has(manifest.kind)) {
if (!registered.has(path.resolve(manifestPath))) throw new Error(`unregistered manifest: ${manifestPath}`)
continue
}
throw new Error(`undeclared legacy manifest: ${manifestPath}`)
}
return inventory
}