/** * Verify that built package declarations are consumable by a standard external * TypeScript ESM project using NodeNext resolution. * * Run after `pnpm run build` has emitted declaration files under package * `lib/types` directories. */ import { execFileSync } from 'node:child_process' import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' const root = resolve(import.meta.dirname, '..') interface ExportTarget { types?: string } interface PackageManifest { name?: string types?: string exports?: Record } interface WorkspacePackage { dir: string name: string manifest: PackageManifest } function readPackage(path: string): WorkspacePackage | null { const manifest = JSON.parse(readFileSync(path, 'utf8')) as PackageManifest if (!manifest.name) return null return { dir: dirname(path), name: manifest.name, manifest } } function workspacePackages(): WorkspacePackage[] { return [ ...globSync('vendor/*/package.json', { cwd: root }), ...globSync('packages/*/*/package.json', { cwd: root }), ] .map(path => readPackage(resolve(root, path))) .filter(pkg => pkg !== null) .sort((a, b) => a.name.localeCompare(b.name)) } const declarationSpecifierPattern = /(?:from\s*|import\s*\(\s*|import\s+|declare\s+module\s*)["'](\.{0,2}(?:\/[^"']*)?)["']/g const hasExtension = /\.[^/.]+$/ function relativeSpecifiersMissingExtensions(): string[] { const errors: string[] = [] const files = [ ...globSync('vendor/*/lib/types/**/*.d.ts', { cwd: root }), ...globSync('packages/*/*/lib/types/**/*.d.ts', { cwd: root }), ].sort() for (const file of files) { const text = readFileSync(resolve(root, file), 'utf8') for (const match of text.matchAll(declarationSpecifierPattern)) { const specifier = match[1] if (!specifier) continue const isRelative = specifier === '.' || specifier.startsWith('./') || specifier.startsWith('../') if (isRelative && !hasExtension.test(specifier)) errors.push(`${file}: ${specifier}`) } } return errors } function publicSpecifiers(pkg: WorkspacePackage): string[] { const specifiers = new Set() if (pkg.manifest.types) specifiers.add(pkg.name) for (const [key, target] of Object.entries(pkg.manifest.exports ?? {})) { if (key.includes('*') || key === './package.json') continue if (typeof target !== 'object' || target === null || !target.types) continue specifiers.add(key === '.' ? pkg.name : `${pkg.name}/${key.slice(2)}`) } return [...specifiers].sort() } function linkPackage(pkg: WorkspacePackage, nodeModules: string): void { const parts = pkg.name.split('/') const link = resolve(nodeModules, ...parts) mkdirSync(dirname(link), { recursive: true }) symlinkSync(pkg.dir, link, 'dir') } const packages = workspacePackages() const badSpecifiers = relativeSpecifiersMissingExtensions() if (badSpecifiers.length > 0) { console.error('verify-node-next-types: declaration files still contain relative specifiers without file extensions.') console.error(badSpecifiers.join('\n')) process.exit(1) } const missingOutputs = packages .filter(pkg => pkg.manifest.types && !existsSync(resolve(pkg.dir, pkg.manifest.types))) .map(pkg => `${pkg.name}: missing ${pkg.manifest.types}`) if (missingOutputs.length > 0) { console.error('verify-node-next-types: build outputs are missing; run `pnpm run build` first.') console.error(missingOutputs.join('\n')) process.exit(1) } const tmp = mkdtempSync(resolve(root, '.node-next-types-')) let failed = false try { const nodeModules = resolve(tmp, 'node_modules') mkdirSync(nodeModules, { recursive: true }) for (const pkg of packages) linkPackage(pkg, nodeModules) const rootTypes = resolve(root, 'node_modules/@types/node') if (existsSync(rootTypes)) { const typesDir = resolve(nodeModules, '@types') mkdirSync(typesDir, { recursive: true }) symlinkSync(rootTypes, resolve(typesDir, 'node'), 'dir') } writeFileSync(resolve(tmp, 'package.json'), `${JSON.stringify({ type: 'module', private: true }, null, 2)}\n`) writeFileSync(resolve(tmp, 'tsconfig.json'), `${JSON.stringify({ compilerOptions: { target: 'es2024', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, // Third-party SDK declarations can have their own lib-check noise under a // symlinked temp install. The explicit scan above owns our regression: // relative specifiers without file extensions in built declarations. skipLibCheck: true, preserveSymlinks: true, noEmit: true, types: ['node'], }, include: ['index.ts'], }, null, 2)}\n`) const imports = packages.flatMap(publicSpecifiers) .map((specifier, index) => `import * as mod${index} from ${JSON.stringify(specifier)};\nvoid mod${index};`) .join('\n') writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`) // tsc's JS entry via the current node, not the .bin shim: the extensionless // shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd variant needs // shell:true, which space-joins args UNESCAPED (DEP0190) — a hazard for the // temp tsconfig path. The JS entry behaves identically on every platform. execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], { cwd: root, stdio: 'pipe', }) console.log(`verify-node-next-types: ${packages.length} workspace package declaration API(s) compile under NodeNext.`) } catch (error: unknown) { failed = true const output = error as { stdout?: Buffer; stderr?: Buffer } console.error('verify-node-next-types: NodeNext consumer typecheck failed.\n') console.error(`${output.stdout?.toString() ?? ''}${output.stderr?.toString() ?? ''}`) } finally { rmSync(tmp, { recursive: true, force: true }) } if (failed) process.exit(1)