接口开始5%

This commit is contained in:
rain
2026-07-22 17:31:33 +08:00
parent eced3d1e6e
commit 9b0ad62df4
426 changed files with 20111 additions and 28472 deletions
@@ -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
}