verify-renderer-assets.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. import { existsSync, statSync } from 'node:fs'
  2. import { readdir, readFile } from 'node:fs/promises'
  3. import { dirname, extname, join, normalize, relative, resolve } from 'node:path'
  4. import { spawnSync } from 'node:child_process'
  5. import { createRequire } from 'node:module'
  6. import { pathToFileURL, fileURLToPath } from 'node:url'
  7. import {
  8. collectPackageEntrypoints,
  9. loadEcosystemReleaseContext
  10. } from './lib/ecosystem-packages.mjs'
  11. import { rendererModularizationLines } from './renderer-dependency-plan.mjs'
  12. const scriptDir = dirname(fileURLToPath(import.meta.url))
  13. const sourceRoot = resolve(scriptDir, '..')
  14. const { entries } = await loadEcosystemReleaseContext(sourceRoot)
  15. const coreEntry = entries.find(entry => entry.packageName === '@file-viewer/core')
  16. const webEntry = entries.find(entry => entry.packageName === '@file-viewer/web')
  17. const rendererEntries = entries.filter(entry => entry.kind === 'renderer')
  18. if (!coreEntry) {
  19. throw new Error('Missing @file-viewer/core release entry')
  20. }
  21. if (!webEntry) {
  22. throw new Error('Missing @file-viewer/web release entry')
  23. }
  24. const {
  25. DEFAULT_FILE_VIEWER_RENDERER_ASSET_MANIFESTS
  26. } = await import(pathToFileURL(join(coreEntry.absoluteDir, 'dist', 'platform', 'assets.js')).href)
  27. const coreRequire = createRequire(join(coreEntry.absoluteDir, 'package.json'))
  28. const rendererEntryByPackageName = new Map(
  29. rendererEntries.map(entry => [entry.packageName, entry])
  30. )
  31. const rendererEntryByRendererId = new Map()
  32. for (const entry of rendererEntries) {
  33. if (entry.renderer?.id) {
  34. rendererEntryByRendererId.set(entry.renderer.id, entry)
  35. }
  36. }
  37. for (const line of rendererModularizationLines) {
  38. const targetEntry = rendererEntryByPackageName.get(line.targetPackage)
  39. if (!targetEntry) {
  40. continue
  41. }
  42. for (const rendererId of line.renderers) {
  43. rendererEntryByRendererId.set(rendererId, targetEntry)
  44. }
  45. }
  46. const plannedRendererIds = new Set(
  47. rendererModularizationLines.flatMap(line => line.renderers)
  48. )
  49. const allowedAssetKinds = new Set([
  50. 'bundled-wasm',
  51. 'directory',
  52. 'script',
  53. 'wasm',
  54. 'wasm-directory',
  55. 'worker'
  56. ])
  57. const allowedAssetTargets = new Set(['bundled', 'external', 'public'])
  58. const deniedPathFragments = ['..', '\\']
  59. function run(command, commandArgs, options = {}) {
  60. const result = spawnSync(command, commandArgs, {
  61. cwd: options.cwd || sourceRoot,
  62. encoding: 'utf8',
  63. stdio: options.capture ? 'pipe' : 'inherit',
  64. env: {
  65. ...process.env,
  66. npm_config_loglevel: 'error'
  67. }
  68. })
  69. if (result.status !== 0) {
  70. throw new Error(`Command failed: ${command} ${commandArgs.join(' ')}`)
  71. }
  72. return result.stdout || ''
  73. }
  74. function normalizePackPath(path) {
  75. return path.replace(/^package\//, '').replace(/\\/g, '/')
  76. }
  77. function parseNpmPackJson(output, packageName) {
  78. const jsonStart = output.indexOf('[')
  79. if (jsonStart === -1) {
  80. throw new Error(`${packageName} npm pack dry-run did not return JSON output`)
  81. }
  82. const parsed = JSON.parse(output.slice(jsonStart))
  83. if (!Array.isArray(parsed) || parsed.length !== 1) {
  84. throw new Error(`${packageName} npm pack dry-run returned an unexpected payload`)
  85. }
  86. return parsed[0]
  87. }
  88. function readDryRunPack(entry) {
  89. const output = run('npm', ['pack', '--dry-run', '--json'], {
  90. cwd: entry.absoluteDir,
  91. capture: true
  92. })
  93. const payload = parseNpmPackJson(output, entry.packageName)
  94. return {
  95. files: payload.files.map(file => normalizePackPath(file.path))
  96. }
  97. }
  98. function assert(condition, message) {
  99. if (!condition) {
  100. throw new Error(message)
  101. }
  102. }
  103. function assertFile(path, label = path) {
  104. assert(existsSync(path), `Missing ${label}`)
  105. assert(statSync(path).isFile(), `${label} must be a file`)
  106. }
  107. function assertDirectory(path, label = path) {
  108. assert(existsSync(path), `Missing ${label}`)
  109. assert(statSync(path).isDirectory(), `${label} must be a directory`)
  110. }
  111. async function listFiles(dir) {
  112. const entries = await readdir(dir, { withFileTypes: true })
  113. const files = []
  114. for (const entry of entries) {
  115. const path = join(dir, entry.name)
  116. if (entry.isDirectory()) {
  117. files.push(...await listFiles(path))
  118. } else if (entry.isFile()) {
  119. files.push(path)
  120. }
  121. }
  122. return files
  123. }
  124. function isRelativeRuntimeAssetPath(value) {
  125. return (
  126. value &&
  127. !value.startsWith('/') &&
  128. !value.startsWith('#') &&
  129. !value.startsWith('data:') &&
  130. !/^[a-z][a-z0-9+.-]*:/i.test(value)
  131. )
  132. }
  133. function packageRelativePath(entry, absolutePath) {
  134. return relative(entry.absoluteDir, absolutePath).replace(/\\/g, '/')
  135. }
  136. async function collectNewUrlRuntimeAssets(entry) {
  137. const distDir = join(entry.absoluteDir, 'dist')
  138. if (!existsSync(distDir)) {
  139. return []
  140. }
  141. const assets = []
  142. const files = (await listFiles(distDir)).filter(file => extname(file) === '.js')
  143. const pattern = /new\s+URL\(\s*(['"])([^'"]+)\1\s*,\s*import\.meta\.url\s*\)/g
  144. for (const file of files) {
  145. const source = await readFile(file, 'utf8')
  146. let match
  147. while ((match = pattern.exec(source))) {
  148. const rawPath = match[2]
  149. if (!isRelativeRuntimeAssetPath(rawPath)) {
  150. continue
  151. }
  152. const absoluteAssetPath = resolve(dirname(file), rawPath)
  153. assets.push({
  154. from: packageRelativePath(entry, file),
  155. rawPath,
  156. packagePath: packageRelativePath(entry, absoluteAssetPath),
  157. absolutePath: absoluteAssetPath
  158. })
  159. }
  160. }
  161. return assets
  162. }
  163. function assertEntrypointsPacked(entry, files) {
  164. const packedFiles = new Set(files)
  165. for (const entrypoint of collectPackageEntrypoints(entry.packageJson)) {
  166. if (entrypoint.includes('*')) {
  167. continue
  168. }
  169. const normalized = entrypoint.replace(/^\.\//, '')
  170. assert(
  171. packedFiles.has(normalized),
  172. `${entry.packageName} npm package is missing declared entrypoint ${entrypoint}`
  173. )
  174. }
  175. }
  176. async function verifyRendererPackage(entry) {
  177. const pack = readDryRunPack(entry)
  178. const packedFiles = new Set(pack.files)
  179. for (const requiredFile of ['package.json', 'README.md', 'README.en.md', 'LICENSE']) {
  180. assert(packedFiles.has(requiredFile), `${entry.packageName} npm package is missing ${requiredFile}`)
  181. }
  182. assertEntrypointsPacked(entry, pack.files)
  183. const runtimeAssets = await collectNewUrlRuntimeAssets(entry)
  184. runtimeAssets.forEach(asset => {
  185. assertFile(asset.absolutePath, `${entry.packageName} runtime asset ${asset.rawPath} referenced by ${asset.from}`)
  186. assert(
  187. packedFiles.has(asset.packagePath),
  188. `${entry.packageName} npm package is missing runtime asset ${asset.packagePath} referenced by ${asset.from}`
  189. )
  190. })
  191. return {
  192. fileCount: pack.files.length,
  193. runtimeAssetCount: runtimeAssets.length
  194. }
  195. }
  196. function assertSafeManifestPath(asset, field, value) {
  197. if (!value) {
  198. return
  199. }
  200. assert(!/^[a-z][a-z0-9+.-]*:/i.test(value), `${asset.id}.${field} must not be an absolute URL: ${value}`)
  201. assert(!value.startsWith('/'), `${asset.id}.${field} must not be an absolute path: ${value}`)
  202. const normalized = normalize(value).replace(/\\/g, '/')
  203. assert(
  204. !deniedPathFragments.some(fragment => normalized.split('/').includes(fragment)),
  205. `${asset.id}.${field} must not escape the viewer asset root: ${value}`
  206. )
  207. }
  208. function verifyAssetManifestShape() {
  209. const seenManifestIds = new Set()
  210. const seenAssetIds = new Set()
  211. for (const manifest of DEFAULT_FILE_VIEWER_RENDERER_ASSET_MANIFESTS) {
  212. assert(manifest.rendererId, 'renderer asset manifest must declare rendererId')
  213. assert(!seenManifestIds.has(manifest.rendererId), `Duplicate renderer asset manifest: ${manifest.rendererId}`)
  214. assert(
  215. plannedRendererIds.has(manifest.rendererId),
  216. `${manifest.rendererId} asset manifest is not tracked by renderer-dependency-plan.mjs`
  217. )
  218. seenManifestIds.add(manifest.rendererId)
  219. assert(Array.isArray(manifest.assets) && manifest.assets.length, `${manifest.rendererId} asset manifest must list assets`)
  220. for (const asset of manifest.assets) {
  221. assert(asset.id, `${manifest.rendererId} asset must declare id`)
  222. assert(!seenAssetIds.has(asset.id), `Duplicate renderer asset id: ${asset.id}`)
  223. seenAssetIds.add(asset.id)
  224. assert(asset.rendererId === manifest.rendererId, `${asset.id} rendererId must match its manifest`)
  225. assert(allowedAssetKinds.has(asset.kind), `${asset.id} has unsupported asset kind: ${asset.kind}`)
  226. assert(allowedAssetTargets.has(asset.target), `${asset.id} has unsupported asset target: ${asset.target}`)
  227. assert(typeof asset.required === 'boolean', `${asset.id} must declare required boolean`)
  228. assert(asset.description && asset.description.length >= 12, `${asset.id} must have a useful description`)
  229. assert(asset.defaultPath || asset.defaultUrl || asset.packagePath, `${asset.id} must declare a default path, URL, or packagePath`)
  230. assertSafeManifestPath(asset, 'defaultPath', asset.defaultPath)
  231. if (asset.packagePath) {
  232. const assetEntry = rendererEntryByRendererId.get(asset.rendererId) || coreEntry
  233. const assetRequire = createRequire(join(assetEntry.absoluteDir, 'package.json'))
  234. let resolvedAssetPath
  235. try {
  236. resolvedAssetPath = assetRequire.resolve(asset.packagePath)
  237. } catch (error) {
  238. if (assetEntry === coreEntry) {
  239. throw error
  240. }
  241. resolvedAssetPath = coreRequire.resolve(asset.packagePath)
  242. }
  243. assertFile(resolvedAssetPath, `${asset.id} package asset ${asset.packagePath}`)
  244. }
  245. }
  246. }
  247. return {
  248. manifestCount: DEFAULT_FILE_VIEWER_RENDERER_ASSET_MANIFESTS.length,
  249. assetCount: seenAssetIds.size
  250. }
  251. }
  252. function assertViewerAssetPacked(packFiles, asset) {
  253. if (asset.target !== 'public' || !asset.defaultPath) {
  254. return false
  255. }
  256. const expectedPath = `viewer/${asset.defaultPath}`.replace(/\/+$/, '')
  257. const packedFiles = new Set(packFiles)
  258. if (asset.kind === 'directory' || asset.kind === 'wasm-directory') {
  259. assert(
  260. packFiles.some(file => file === expectedPath || file.startsWith(`${expectedPath}/`)),
  261. `@file-viewer/web npm package is missing viewer asset directory ${expectedPath}`
  262. )
  263. } else {
  264. assert(
  265. packedFiles.has(expectedPath),
  266. `@file-viewer/web npm package is missing viewer asset file ${expectedPath}`
  267. )
  268. }
  269. return true
  270. }
  271. function verifyWebViewerAssets() {
  272. const pack = readDryRunPack(webEntry)
  273. const viewerDir = join(webEntry.absoluteDir, 'viewer')
  274. assertDirectory(viewerDir, '@file-viewer/web viewer/')
  275. let checkedAssetCount = 0
  276. for (const manifest of DEFAULT_FILE_VIEWER_RENDERER_ASSET_MANIFESTS) {
  277. for (const asset of manifest.assets) {
  278. if (asset.target === 'public' && asset.defaultPath) {
  279. const absolutePath = join(viewerDir, asset.defaultPath)
  280. if (asset.kind === 'directory' || asset.kind === 'wasm-directory') {
  281. assertDirectory(absolutePath, `@file-viewer/web viewer/${asset.defaultPath}`)
  282. } else {
  283. assertFile(absolutePath, `@file-viewer/web viewer/${asset.defaultPath}`)
  284. }
  285. }
  286. if (assertViewerAssetPacked(pack.files, asset)) {
  287. checkedAssetCount += 1
  288. }
  289. }
  290. }
  291. return {
  292. fileCount: pack.files.length,
  293. checkedAssetCount
  294. }
  295. }
  296. let checkedRendererFileCount = 0
  297. let checkedRuntimeAssetCount = 0
  298. for (const entry of rendererEntries) {
  299. const result = await verifyRendererPackage(entry)
  300. checkedRendererFileCount += result.fileCount
  301. checkedRuntimeAssetCount += result.runtimeAssetCount
  302. }
  303. const manifestResult = verifyAssetManifestShape()
  304. const webResult = verifyWebViewerAssets()
  305. console.log(
  306. `[renderer-assets] Verified ${rendererEntries.length} renderer npm dry-runs (${checkedRendererFileCount} files), ${checkedRuntimeAssetCount} renderer-local runtime assets, ${manifestResult.manifestCount} core asset manifests / ${manifestResult.assetCount} assets, and ${webResult.checkedAssetCount} @file-viewer/web viewer assets (${webResult.fileCount} packed files).`
  307. )