verify-production-entrypoints.mjs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import { existsSync } from 'node:fs'
  2. import { readFile, stat } from 'node:fs/promises'
  3. import { dirname, extname, join, resolve } from 'node:path'
  4. import { pathToFileURL, fileURLToPath } from 'node:url'
  5. import { TextDecoder, TextEncoder } from 'node:util'
  6. import vm from 'node:vm'
  7. import {
  8. collectExportEntrypoints,
  9. collectPackageEntrypoints,
  10. loadEcosystemReleaseContext
  11. } from './lib/ecosystem-packages.mjs'
  12. import { allowedEntryFormats } from './lib/wrapper-entry-formats.mjs'
  13. const scriptDir = dirname(fileURLToPath(import.meta.url))
  14. const sourceRoot = resolve(scriptDir, '..')
  15. const { entries, wrapperManifest } = await loadEcosystemReleaseContext(sourceRoot)
  16. const importableExtensions = new Set(['.js', '.mjs'])
  17. const webGlobalPackages = new Set([
  18. '@flyfish-group/file-viewer-web',
  19. '@file-viewer/web'
  20. ])
  21. const webGlobalBundle = 'dist/flyfish-file-viewer-web.iife.js'
  22. const wrappersByPackageName = new Map(wrapperManifest.wrappers.map(wrapper => [wrapper.packageName, wrapper]))
  23. const BrowserCustomEvent = globalThis.CustomEvent ?? class CustomEvent extends Event {
  24. constructor(type, init = {}) {
  25. super(type, init)
  26. this.detail = init.detail
  27. }
  28. }
  29. async function assertFile(path, label = path) {
  30. if (!existsSync(path)) {
  31. throw new Error(`Missing production entrypoint: ${label}`)
  32. }
  33. const info = await stat(path)
  34. if (!info.isFile()) {
  35. throw new Error(`Production entrypoint is not a file: ${label}`)
  36. }
  37. }
  38. async function assertDirectory(path, label = path) {
  39. if (!existsSync(path)) {
  40. throw new Error(`Missing production directory: ${label}`)
  41. }
  42. const info = await stat(path)
  43. if (!info.isDirectory()) {
  44. throw new Error(`Production entrypoint is not a directory: ${label}`)
  45. }
  46. }
  47. function normalizeEntrypoint(entrypoint) {
  48. return entrypoint.replace(/^\.\//, '')
  49. }
  50. function isImportableEntrypoint(entrypoint) {
  51. const normalized = normalizeEntrypoint(entrypoint)
  52. return importableExtensions.has(extname(normalized)) && !normalized.includes('*')
  53. }
  54. function collectProductionImportEntrypoints(packageJson) {
  55. const entrypoints = new Set()
  56. for (const field of ['main', 'module', 'browser']) {
  57. if (typeof packageJson[field] === 'string' && isImportableEntrypoint(packageJson[field])) {
  58. entrypoints.add(packageJson[field])
  59. }
  60. }
  61. for (const entrypoint of collectExportEntrypoints(packageJson.exports)) {
  62. if (isImportableEntrypoint(entrypoint)) {
  63. entrypoints.add(entrypoint)
  64. }
  65. }
  66. return [...entrypoints]
  67. }
  68. async function assertPackageEntrypoints(entry) {
  69. for (const entrypoint of collectPackageEntrypoints(entry.packageJson)) {
  70. await assertFile(
  71. join(entry.absoluteDir, entrypoint),
  72. `${entry.packageName} ${entrypoint}`
  73. )
  74. }
  75. }
  76. async function importProductionEntrypoints(entry) {
  77. for (const entrypoint of collectProductionImportEntrypoints(entry.packageJson)) {
  78. const absolutePath = join(entry.absoluteDir, entrypoint)
  79. await import(pathToFileURL(absolutePath).href)
  80. }
  81. }
  82. async function assertViewerStaticEntrypoints(entry) {
  83. const viewerDir = join(entry.absoluteDir, 'viewer')
  84. await assertDirectory(viewerDir, `${entry.packageName} viewer/`)
  85. for (const file of [
  86. 'flyfish-viewer-assets.json',
  87. 'flyfish-viewer-manifest.json'
  88. ]) {
  89. await assertFile(join(viewerDir, file), `${entry.packageName} viewer/${file}`)
  90. }
  91. await assertDirectory(join(viewerDir, 'wasm'), `${entry.packageName} viewer/wasm/`)
  92. }
  93. async function assertWebGlobalEntrypoint(entry) {
  94. if (!webGlobalPackages.has(entry.packageName)) {
  95. return false
  96. }
  97. if (!entry.packageJson.unpkg || !entry.packageJson.jsdelivr) {
  98. throw new Error(`${entry.packageName} browser global package must declare both unpkg and jsdelivr bundle fields`)
  99. }
  100. const bundleEntrypoints = new Set([
  101. entry.packageJson.unpkg,
  102. entry.packageJson.jsdelivr,
  103. `./${webGlobalBundle}`
  104. ])
  105. for (const bundleEntrypoint of bundleEntrypoints) {
  106. const normalizedEntrypoint = normalizeEntrypoint(bundleEntrypoint)
  107. const bundlePath = join(entry.absoluteDir, normalizedEntrypoint)
  108. await assertFile(bundlePath, `${entry.packageName} ${bundleEntrypoint}`)
  109. const navigator = { userAgent: 'Mozilla/5.0 (FileViewerEntrypointCheck)' }
  110. const browserGlobals = {
  111. Blob,
  112. DOMException,
  113. Event,
  114. EventTarget,
  115. CustomEvent: BrowserCustomEvent,
  116. navigator,
  117. requestAnimationFrame: callback => setTimeout(() => callback(Date.now()), 0),
  118. cancelAnimationFrame: id => clearTimeout(id),
  119. TextDecoder,
  120. TextEncoder,
  121. URL,
  122. URLSearchParams
  123. }
  124. const context = {
  125. ...browserGlobals,
  126. self: browserGlobals,
  127. window: browserGlobals
  128. }
  129. vm.runInNewContext(await readFile(bundlePath, 'utf8'), context, {
  130. filename: `${entry.packageName}/${normalizedEntrypoint}`
  131. })
  132. const globalApi = context.window.FlyfishFileViewerWeb || context.FlyfishFileViewerWeb
  133. for (const exportName of [
  134. 'mountViewer',
  135. 'createViewerControllerHandle'
  136. ]) {
  137. if (typeof globalApi?.[exportName] !== 'function') {
  138. throw new Error(`${entry.packageName} browser global bundle ${bundleEntrypoint} is missing ${exportName}`)
  139. }
  140. }
  141. }
  142. return true
  143. }
  144. async function assertRootVue3StaticEntrypoints(entry) {
  145. if (entry.packageDir !== '.') {
  146. return
  147. }
  148. await assertFile(join(entry.absoluteDir, 'dist', 'file-viewer3.css'), `${entry.packageName} dist/file-viewer3.css`)
  149. await assertDirectory(join(entry.absoluteDir, 'dist', 'components'), `${entry.packageName} dist/components/`)
  150. }
  151. async function assertEntrypointFieldFile(entry, fieldName, value) {
  152. if (typeof value !== 'string') {
  153. throw new Error(`${entry.packageName} entry format requires ${fieldName}`)
  154. }
  155. await assertFile(
  156. join(entry.absoluteDir, normalizeEntrypoint(value)),
  157. `${entry.packageName} ${fieldName} ${value}`
  158. )
  159. }
  160. async function assertWrapperEntryFormats(entry) {
  161. const wrapper = wrappersByPackageName.get(entry.packageName)
  162. if (!wrapper) {
  163. return 0
  164. }
  165. if (!Array.isArray(wrapper.entryFormats) || !wrapper.entryFormats.length) {
  166. throw new Error(`${entry.packageName} wrapper manifest must declare entryFormats`)
  167. }
  168. for (const format of wrapper.entryFormats) {
  169. if (!allowedEntryFormats.has(format)) {
  170. throw new Error(`${entry.packageName} wrapper manifest has unsupported entry format: ${format}`)
  171. }
  172. }
  173. if (wrapper.entryFormats.includes('esm')) {
  174. await assertEntrypointFieldFile(entry, 'module', entry.packageJson.module)
  175. await assertEntrypointFieldFile(entry, 'exports["."].import', entry.packageJson.exports?.['.']?.import)
  176. }
  177. if (wrapper.entryFormats.includes('types')) {
  178. await assertEntrypointFieldFile(entry, 'types', entry.packageJson.types)
  179. await assertEntrypointFieldFile(entry, 'exports["."].types', entry.packageJson.exports?.['.']?.types)
  180. }
  181. if (wrapper.entryFormats.includes('iife')) {
  182. await assertEntrypointFieldFile(entry, 'unpkg', entry.packageJson.unpkg)
  183. await assertEntrypointFieldFile(entry, 'jsdelivr', entry.packageJson.jsdelivr)
  184. await assertWebGlobalEntrypoint(entry)
  185. }
  186. if (wrapper.entryFormats.includes('viewer-assets')) {
  187. if (!entry.packageJson.exports?.['./viewer/*']) {
  188. throw new Error(`${entry.packageName} declares viewer-assets but package.json is missing exports["./viewer/*"]`)
  189. }
  190. if (!entry.packageJson.files?.includes('viewer')) {
  191. throw new Error(`${entry.packageName} declares viewer-assets but package.json files does not include viewer`)
  192. }
  193. await assertViewerStaticEntrypoints(entry)
  194. }
  195. if (wrapper.entryFormats.includes('copy-assets-cli')) {
  196. await assertEntrypointFieldFile(
  197. entry,
  198. 'bin.file-viewer-copy-assets',
  199. entry.packageJson.bin?.['file-viewer-copy-assets']
  200. )
  201. }
  202. if (wrapper.entryFormats.includes('svelte-component')) {
  203. await assertEntrypointFieldFile(entry, 'svelte', entry.packageJson.svelte)
  204. await assertEntrypointFieldFile(entry, 'exports["."].svelte', entry.packageJson.exports?.['.']?.svelte)
  205. }
  206. return wrapper.entryFormats.length
  207. }
  208. let checkedEntrypointCount = 0
  209. let importedEntrypointCount = 0
  210. let checkedGlobalBundleCount = 0
  211. let checkedWrapperEntryFormatCount = 0
  212. for (const entry of entries) {
  213. await assertPackageEntrypoints(entry)
  214. checkedEntrypointCount += collectPackageEntrypoints(entry.packageJson).length
  215. const importEntrypoints = collectProductionImportEntrypoints(entry.packageJson)
  216. await importProductionEntrypoints(entry)
  217. importedEntrypointCount += importEntrypoints.length
  218. if (webGlobalPackages.has(entry.packageName)) {
  219. await assertViewerStaticEntrypoints(entry)
  220. }
  221. if (await assertWebGlobalEntrypoint(entry)) {
  222. checkedGlobalBundleCount += 1
  223. }
  224. checkedWrapperEntryFormatCount += await assertWrapperEntryFormats(entry)
  225. await assertRootVue3StaticEntrypoints(entry)
  226. }
  227. console.log(
  228. `[production-entrypoints] Verified ${entries.length} packages, ${checkedEntrypointCount} declared entrypoint files, ${importedEntrypointCount} importable ESM production entrypoints, ${checkedGlobalBundleCount} browser global bundles, and ${checkedWrapperEntryFormatCount} component entry format claims.`
  229. )