verify-node-next-types.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. /**
  2. * Verify that built package declarations are consumable by a standard external
  3. * TypeScript ESM project using NodeNext resolution.
  4. *
  5. * Run after `pnpm run build` has emitted declaration files under package
  6. * `lib/types` directories.
  7. */
  8. import { execFileSync } from 'node:child_process'
  9. import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
  10. import { dirname, resolve } from 'node:path'
  11. const isWindows = process.platform === 'win32'
  12. const root = resolve(import.meta.dirname, '..')
  13. interface ExportTarget {
  14. types?: string
  15. }
  16. interface PackageManifest {
  17. name?: string
  18. types?: string
  19. exports?: Record<string, ExportTarget | string | null>
  20. }
  21. interface WorkspacePackage {
  22. dir: string
  23. name: string
  24. manifest: PackageManifest
  25. }
  26. function readPackage(path: string): WorkspacePackage | null {
  27. const manifest = JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
  28. if (!manifest.name) return null
  29. return { dir: dirname(path), name: manifest.name, manifest }
  30. }
  31. function workspacePackages(): WorkspacePackage[] {
  32. return [
  33. ...globSync('vendor/*/package.json', { cwd: root }),
  34. ...globSync('packages/*/*/package.json', { cwd: root }),
  35. ]
  36. .map(path => readPackage(resolve(root, path)))
  37. .filter(pkg => pkg !== null)
  38. .sort((a, b) => a.name.localeCompare(b.name))
  39. }
  40. const declarationSpecifierPattern = /(?:from\s*|import\s*\(\s*|import\s+|declare\s+module\s*)["'](\.{0,2}(?:\/[^"']*)?)["']/g
  41. const hasExtension = /\.[^/.]+$/
  42. function relativeSpecifiersMissingExtensions(): string[] {
  43. const errors: string[] = []
  44. const files = [
  45. ...globSync('vendor/*/lib/types/**/*.d.ts', { cwd: root }),
  46. ...globSync('packages/*/*/lib/types/**/*.d.ts', { cwd: root }),
  47. ].sort()
  48. for (const file of files) {
  49. const text = readFileSync(resolve(root, file), 'utf8')
  50. for (const match of text.matchAll(declarationSpecifierPattern)) {
  51. const specifier = match[1]
  52. if (!specifier) continue
  53. const isRelative = specifier === '.' || specifier.startsWith('./') || specifier.startsWith('../')
  54. if (isRelative && !hasExtension.test(specifier)) errors.push(`${file}: ${specifier}`)
  55. }
  56. }
  57. return errors
  58. }
  59. function publicSpecifiers(pkg: WorkspacePackage): string[] {
  60. const specifiers = new Set<string>()
  61. if (pkg.manifest.types) specifiers.add(pkg.name)
  62. for (const [key, target] of Object.entries(pkg.manifest.exports ?? {})) {
  63. if (key.includes('*') || key === './package.json') continue
  64. if (typeof target !== 'object' || target === null || !target.types) continue
  65. specifiers.add(key === '.' ? pkg.name : `${pkg.name}/${key.slice(2)}`)
  66. }
  67. return [...specifiers].sort()
  68. }
  69. function linkPackage(pkg: WorkspacePackage, nodeModules: string): void {
  70. const parts = pkg.name.split('/')
  71. const link = resolve(nodeModules, ...parts)
  72. mkdirSync(dirname(link), { recursive: true })
  73. symlinkSync(pkg.dir, link, 'dir')
  74. }
  75. const packages = workspacePackages()
  76. const badSpecifiers = relativeSpecifiersMissingExtensions()
  77. if (badSpecifiers.length > 0) {
  78. console.error('verify-node-next-types: declaration files still contain relative specifiers without file extensions.')
  79. console.error(badSpecifiers.join('\n'))
  80. process.exit(1)
  81. }
  82. const missingOutputs = packages
  83. .filter(pkg => pkg.manifest.types && !existsSync(resolve(pkg.dir, pkg.manifest.types)))
  84. .map(pkg => `${pkg.name}: missing ${pkg.manifest.types}`)
  85. if (missingOutputs.length > 0) {
  86. console.error('verify-node-next-types: build outputs are missing; run `pnpm run build` first.')
  87. console.error(missingOutputs.join('\n'))
  88. process.exit(1)
  89. }
  90. const tmp = mkdtempSync(resolve(root, '.node-next-types-'))
  91. let failed = false
  92. try {
  93. const nodeModules = resolve(tmp, 'node_modules')
  94. mkdirSync(nodeModules, { recursive: true })
  95. for (const pkg of packages) linkPackage(pkg, nodeModules)
  96. const rootTypes = resolve(root, 'node_modules/@types/node')
  97. if (existsSync(rootTypes)) {
  98. const typesDir = resolve(nodeModules, '@types')
  99. mkdirSync(typesDir, { recursive: true })
  100. symlinkSync(rootTypes, resolve(typesDir, 'node'), 'dir')
  101. }
  102. writeFileSync(resolve(tmp, 'package.json'), `${JSON.stringify({ type: 'module', private: true }, null, 2)}\n`)
  103. writeFileSync(resolve(tmp, 'tsconfig.json'), `${JSON.stringify({
  104. compilerOptions: {
  105. target: 'es2024',
  106. module: 'NodeNext',
  107. moduleResolution: 'NodeNext',
  108. strict: true,
  109. // Third-party SDK declarations can have their own lib-check noise under a
  110. // symlinked temp install. The explicit scan above owns our regression:
  111. // relative specifiers without file extensions in built declarations.
  112. skipLibCheck: true,
  113. preserveSymlinks: true,
  114. noEmit: true,
  115. types: ['node'],
  116. },
  117. include: ['index.ts'],
  118. }, null, 2)}\n`)
  119. const imports = packages.flatMap(publicSpecifiers)
  120. .map((specifier, index) => `import * as mod${index} from ${JSON.stringify(specifier)};\nvoid mod${index};`)
  121. .join('\n')
  122. writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`)
  123. // On Windows the bin shim is a .cmd file; recent Node (CVE-2024-27980)
  124. // refuses to launch .cmd/.bat via execFileSync without shell:true.
  125. const tscBin = isWindows ? resolve(root, 'node_modules/.bin/tsc.cmd') : resolve(root, 'node_modules/.bin/tsc')
  126. execFileSync(tscBin, ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], {
  127. cwd: root,
  128. stdio: 'pipe',
  129. shell: isWindows,
  130. })
  131. console.log(`verify-node-next-types: ${packages.length} workspace package declaration surface(s) compile under NodeNext.`)
  132. } catch (error: unknown) {
  133. failed = true
  134. const output = error as { stdout?: Buffer; stderr?: Buffer }
  135. console.error('verify-node-next-types: NodeNext consumer typecheck failed.\n')
  136. console.error(`${output.stdout?.toString() ?? ''}${output.stderr?.toString() ?? ''}`)
  137. } finally {
  138. rmSync(tmp, { recursive: true, force: true })
  139. }
  140. if (failed) process.exit(1)