接口开始5%
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
|
||||
const formalManifestKinds = new Set(['asset-build-manifest', '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 === 'asset-build-manifest') {
|
||||
validateAssetBuildManifest(manifest, workspacePath)
|
||||
for (const asset of manifest.assets) {
|
||||
addAsset(
|
||||
{
|
||||
id: asset.id,
|
||||
output: asset.output,
|
||||
width: asset.outputPixels.width,
|
||||
height: asset.outputPixels.height,
|
||||
// 不透明构建模式依法不声明 alpha;只有透明模式存在该对象,避免把“字段缺失”误判为清单损坏。
|
||||
alpha: asset.alpha?.required ?? false,
|
||||
maxBytes: asset.quality.maxBytes,
|
||||
provenance: 'generated-from-manifest',
|
||||
rebuildable: true,
|
||||
},
|
||||
absolutePath,
|
||||
)
|
||||
}
|
||||
} else 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)))
|
||||
|
||||
// 顶层注册表必须覆盖目录内每一份正式 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
|
||||
}
|
||||
Reference in New Issue
Block a user