From f9c427a80b88413238454c5d4563cc8be5f7f9b9 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 9 Jun 2025 13:58:57 +0100 Subject: [PATCH] fix(webpack): handle package.json exports field for non-buildable libs (#31444) Current Behavior The webpack and rspack plugins for handling non-buildable libraries don't properly process the exports field in package.json. They incorrectly assume libraries have only a single entry point, typically through a barrel file (index.ts). When a library defines multiple export paths using the exports field (e.g., "./*": "./src/*.ts"), the plugins fail to generate the correct allowlist patterns for webpack externals. This causes build failures when trying to use non-buildable libraries that expose multiple entry points without barrel files. Expected Behavior The webpack and rspack plugins should properly parse the exports field from package.json and generate appropriate allowlist patterns for all exported subpaths. This includes: Handling wildcard patterns ("./*": "./src/*.ts") Processing conditional exports (import/require/development) Supporting exact subpath exports ("./utils": "./src/utils.ts") Escaping special characters in package names for regex patterns Gracefully falling back to reading package.json directly when metadata is unavailable Co-authored-by: Claude --- .../utils/get-non-buildable-libs.spec.ts | 154 ++++++++++++++++++ .../plugins/utils/get-non-buildable-libs.ts | 109 ++++++++++++- .../nx-webpack-plugin/lib/utils.spec.ts | 154 ++++++++++++++++++ .../plugins/nx-webpack-plugin/lib/utils.ts | 108 +++++++++++- 4 files changed, 512 insertions(+), 13 deletions(-) create mode 100644 packages/rspack/src/plugins/utils/get-non-buildable-libs.spec.ts create mode 100644 packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.spec.ts diff --git a/packages/rspack/src/plugins/utils/get-non-buildable-libs.spec.ts b/packages/rspack/src/plugins/utils/get-non-buildable-libs.spec.ts new file mode 100644 index 0000000000..cecc4c99a5 --- /dev/null +++ b/packages/rspack/src/plugins/utils/get-non-buildable-libs.spec.ts @@ -0,0 +1,154 @@ +import { logger } from '@nx/devkit'; +import { createAllowlistFromExports } from './get-non-buildable-libs'; + +describe('createAllowlistFromExports', () => { + beforeEach(() => { + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should handle undefined exports', () => { + const result = createAllowlistFromExports('@test/lib', undefined); + expect(result).toEqual(['@test/lib']); + }); + + it('should handle string exports', () => { + const result = createAllowlistFromExports('@test/lib', './index.js'); + expect(result).toEqual(['@test/lib']); + }); + + it('should handle wildcard exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@test/lib/utils')).toBe(true); + expect(regex.test('@test/lib/nested/path')).toBe(true); + expect(regex.test('@other/lib/utils')).toBe(false); + expect(regex.test('@test/lib')).toBe(false); + }); + + it('should handle exact subpath exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': './src/utils.ts', + './types': './src/types.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils', '@test/lib/types']); + }); + + it('should handle conditional exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + import: './src/utils.mjs', + require: './src/utils.cjs', + default: './src/utils.js', + }, + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle conditional exports with development priority', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + development: './src/utils.ts', + import: './src/utils.mjs', + require: './src/utils.cjs', + default: './src/utils.js', + }, + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle mixed patterns', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': './src/utils.ts', + './*': './src/*.ts', + }); + expect(result).toHaveLength(3); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBe('@test/lib/utils'); + expect(result[2]).toBeInstanceOf(RegExp); + + const regex = result[2] as RegExp; + expect(regex.test('@test/lib/helpers')).toBe(true); + expect(regex.test('@test/lib/utils')).toBe(true); // Also matches regex + }); + + it('should escape special characters in package names', () => { + const result = createAllowlistFromExports('@test/lib.name', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@test/lib.name/utils')).toBe(true); + expect(regex.test('@test/lib-name/utils')).toBe(false); + }); + + it('should handle scoped package names with special characters', () => { + const result = createAllowlistFromExports('@my-org/my-lib.pkg', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@my-org/my-lib.pkg/utils')).toBe(true); + expect(regex.test('@my-org/my-lib-pkg/utils')).toBe(false); + }); + + it('should handle complex wildcard patterns', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils/*': './src/utils/*.ts', + './types/*': './src/types/*.ts', + }); + expect(result).toHaveLength(3); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBeInstanceOf(RegExp); + expect(result[2]).toBeInstanceOf(RegExp); + + const utilsRegex = result[1] as RegExp; + const typesRegex = result[2] as RegExp; + + expect(utilsRegex.test('@test/lib/utils/helpers')).toBe(true); + expect(utilsRegex.test('@test/lib/types/common')).toBe(false); + expect(typesRegex.test('@test/lib/types/common')).toBe(true); + expect(typesRegex.test('@test/lib/utils/helpers')).toBe(false); + }); + + it('should ignore main export (.)', () => { + const result = createAllowlistFromExports('@test/lib', { + '.': './src/index.ts', + './utils': './src/utils.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle invalid conditional exports gracefully', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + import: null, + require: undefined, + types: './src/types.d.ts', // Should be ignored + }, + './valid': './src/valid.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/valid']); + }); + + it('should handle non-string export paths', () => { + const result = createAllowlistFromExports('@test/lib', { + 123: './src/invalid.ts', + './valid': './src/valid.ts', + } as any); + expect(result).toEqual(['@test/lib', '@test/lib/valid']); + }); +}); diff --git a/packages/rspack/src/plugins/utils/get-non-buildable-libs.ts b/packages/rspack/src/plugins/utils/get-non-buildable-libs.ts index f280c8f9f9..9654b8c4b8 100644 --- a/packages/rspack/src/plugins/utils/get-non-buildable-libs.ts +++ b/packages/rspack/src/plugins/utils/get-non-buildable-libs.ts @@ -1,7 +1,76 @@ -import { type ProjectGraph } from '@nx/devkit'; +import { type ProjectGraph, readJsonFile } from '@nx/devkit'; +import { join } from 'path'; import { getAllTransitiveDeps } from './get-transitive-deps'; import { isBuildableLibrary } from './is-lib-buildable'; +function escapePackageName(packageName: string): string { + return packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function escapeRegexAndConvertWildcard(pattern: string): string { + return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*'); +} + +function resolveConditionalExport(target: any): string | null { + if (typeof target === 'string') { + return target; + } + + if (typeof target === 'object' && target !== null) { + // Priority order for conditions + const conditions = ['development', 'import', 'require', 'default']; + for (const condition of conditions) { + if (target[condition] && typeof target[condition] === 'string') { + return target[condition]; + } + } + } + + return null; +} + +export function createAllowlistFromExports( + packageName: string, + exports: Record | string | undefined +): (string | RegExp)[] { + if (!exports) { + return [packageName]; + } + + const allowlist: (string | RegExp)[] = []; + allowlist.push(packageName); + + if (typeof exports === 'string') { + return allowlist; + } + + if (typeof exports === 'object') { + for (const [exportPath, target] of Object.entries(exports)) { + if (typeof exportPath !== 'string') continue; + + const resolvedTarget = resolveConditionalExport(target); + if (!resolvedTarget) continue; + + if (exportPath === '.') { + continue; + } else if (exportPath.startsWith('./')) { + const subpath = exportPath.slice(2); + + if (subpath.includes('*')) { + const regexPattern = escapeRegexAndConvertWildcard(subpath); + allowlist.push( + new RegExp(`^${escapePackageName(packageName)}/${regexPattern}$`) + ); + } else { + allowlist.push(`${packageName}/${subpath}`); + } + } + } + } + + return allowlist; +} + /** * Get all non-buildable libraries in the project graph for a given project. * This function retrieves all direct and transitive dependencies of a project, @@ -14,10 +83,10 @@ import { isBuildableLibrary } from './is-lib-buildable'; export function getNonBuildableLibs( graph: ProjectGraph, projectName: string -): string[] { +): (string | RegExp)[] { const deps = graph?.dependencies?.[projectName] ?? []; - const allNonBuildable = new Set(); + const allNonBuildable = new Set(); // First, find all direct non-buildable deps and add them App -> library const directNonBuildable = deps.filter((dep) => { @@ -28,12 +97,38 @@ export function getNonBuildableLibs( return !isBuildableLibrary(node); }); - // Add direct non-buildable dependencies + // Add direct non-buildable dependencies with expanded export patterns for (const dep of directNonBuildable) { - const packageName = - graph.nodes?.[dep.target]?.data?.metadata?.js?.packageName; + const node = graph.nodes?.[dep.target]; + const packageName = node?.data?.metadata?.js?.packageName; + if (packageName) { - allNonBuildable.add(packageName); + // Get exports from project metadata first (most reliable) + const packageExports = node?.data?.metadata?.js?.packageExports; + + if (packageExports) { + // Use metadata exports if available + const allowlistPatterns = createAllowlistFromExports( + packageName, + packageExports + ); + allowlistPatterns.forEach((pattern) => allNonBuildable.add(pattern)); + } else { + // Fallback: try to read package.json directly + try { + const projectRoot = node.data.root; + const packageJsonPath = join(projectRoot, 'package.json'); + const packageJson = readJsonFile(packageJsonPath); + const allowlistPatterns = createAllowlistFromExports( + packageName, + packageJson.exports + ); + allowlistPatterns.forEach((pattern) => allNonBuildable.add(pattern)); + } catch (error) { + // Final fallback: just add base package name + allNonBuildable.add(packageName); + } + } } // Get all transitive non-buildable dependencies App -> library1 -> library2 diff --git a/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.spec.ts b/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.spec.ts new file mode 100644 index 0000000000..76e0b71800 --- /dev/null +++ b/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.spec.ts @@ -0,0 +1,154 @@ +import { logger } from '@nx/devkit'; +import { createAllowlistFromExports } from './utils'; + +describe('createAllowlistFromExports', () => { + beforeEach(() => { + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should handle undefined exports', () => { + const result = createAllowlistFromExports('@test/lib', undefined); + expect(result).toEqual(['@test/lib']); + }); + + it('should handle string exports', () => { + const result = createAllowlistFromExports('@test/lib', './index.js'); + expect(result).toEqual(['@test/lib']); + }); + + it('should handle wildcard exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@test/lib/utils')).toBe(true); + expect(regex.test('@test/lib/nested/path')).toBe(true); + expect(regex.test('@other/lib/utils')).toBe(false); + expect(regex.test('@test/lib')).toBe(false); + }); + + it('should handle exact subpath exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': './src/utils.ts', + './types': './src/types.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils', '@test/lib/types']); + }); + + it('should handle conditional exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + import: './src/utils.mjs', + require: './src/utils.cjs', + default: './src/utils.js', + }, + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle conditional exports with development priority', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + development: './src/utils.ts', + import: './src/utils.mjs', + require: './src/utils.cjs', + default: './src/utils.js', + }, + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle mixed patterns', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': './src/utils.ts', + './*': './src/*.ts', + }); + expect(result).toHaveLength(3); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBe('@test/lib/utils'); + expect(result[2]).toBeInstanceOf(RegExp); + + const regex = result[2] as RegExp; + expect(regex.test('@test/lib/helpers')).toBe(true); + expect(regex.test('@test/lib/utils')).toBe(true); // Also matches regex + }); + + it('should escape special characters in package names', () => { + const result = createAllowlistFromExports('@test/lib.name', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@test/lib.name/utils')).toBe(true); + expect(regex.test('@test/lib-name/utils')).toBe(false); + }); + + it('should handle scoped package names with special characters', () => { + const result = createAllowlistFromExports('@my-org/my-lib.pkg', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@my-org/my-lib.pkg/utils')).toBe(true); + expect(regex.test('@my-org/my-lib-pkg/utils')).toBe(false); + }); + + it('should handle complex wildcard patterns', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils/*': './src/utils/*.ts', + './types/*': './src/types/*.ts', + }); + expect(result).toHaveLength(3); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBeInstanceOf(RegExp); + expect(result[2]).toBeInstanceOf(RegExp); + + const utilsRegex = result[1] as RegExp; + const typesRegex = result[2] as RegExp; + + expect(utilsRegex.test('@test/lib/utils/helpers')).toBe(true); + expect(utilsRegex.test('@test/lib/types/common')).toBe(false); + expect(typesRegex.test('@test/lib/types/common')).toBe(true); + expect(typesRegex.test('@test/lib/utils/helpers')).toBe(false); + }); + + it('should ignore main export (.)', () => { + const result = createAllowlistFromExports('@test/lib', { + '.': './src/index.ts', + './utils': './src/utils.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle invalid conditional exports gracefully', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + import: null, + require: undefined, + types: './src/types.d.ts', // Should be ignored + }, + './valid': './src/valid.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/valid']); + }); + + it('should handle non-string export paths', () => { + const result = createAllowlistFromExports('@test/lib', { + 123: './src/invalid.ts', + './valid': './src/valid.ts', + } as any); + expect(result).toEqual(['@test/lib', '@test/lib/valid']); + }); +}); diff --git a/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.ts b/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.ts index 89d799a6be..da666d2dd7 100644 --- a/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.ts +++ b/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.ts @@ -1,4 +1,74 @@ import type { ProjectGraph, ProjectGraphProjectNode } from '@nx/devkit'; +import { readJsonFile } from '@nx/devkit'; +import { join } from 'path'; + +function escapePackageName(packageName: string): string { + return packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function escapeRegexAndConvertWildcard(pattern: string): string { + return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*'); +} + +function resolveConditionalExport(target: any): string | null { + if (typeof target === 'string') { + return target; + } + + if (typeof target === 'object' && target !== null) { + // Priority order for conditions + const conditions = ['development', 'import', 'require', 'default']; + for (const condition of conditions) { + if (target[condition] && typeof target[condition] === 'string') { + return target[condition]; + } + } + } + + return null; +} + +export function createAllowlistFromExports( + packageName: string, + exports: Record | string | undefined +): (string | RegExp)[] { + if (!exports) { + return [packageName]; + } + + const allowlist: (string | RegExp)[] = []; + allowlist.push(packageName); + + if (typeof exports === 'string') { + return allowlist; + } + + if (typeof exports === 'object') { + for (const [exportPath, target] of Object.entries(exports)) { + if (typeof exportPath !== 'string') continue; + + const resolvedTarget = resolveConditionalExport(target); + if (!resolvedTarget) continue; + + if (exportPath === '.') { + continue; + } else if (exportPath.startsWith('./')) { + const subpath = exportPath.slice(2); + + if (subpath.includes('*')) { + const regexPattern = escapeRegexAndConvertWildcard(subpath); + allowlist.push( + new RegExp(`^${escapePackageName(packageName)}/${regexPattern}$`) + ); + } else { + allowlist.push(`${packageName}/${subpath}`); + } + } + } + } + + return allowlist; +} function isSourceFile(path: string): boolean { return ['.ts', '.tsx', '.mts', '.cts'].some((ext) => path.endsWith(ext)); @@ -122,10 +192,10 @@ export function getAllTransitiveDeps( export function getNonBuildableLibs( graph: ProjectGraph, projectName: string -): string[] { +): (string | RegExp)[] { const deps = graph?.dependencies?.[projectName] ?? []; - const allNonBuildable = new Set(); + const allNonBuildable = new Set(); // First, find all direct non-buildable deps and add them App -> library const directNonBuildable = deps.filter((dep) => { @@ -136,12 +206,38 @@ export function getNonBuildableLibs( return !isBuildableLibrary(node); }); - // Add direct non-buildable dependencies + // Add direct non-buildable dependencies with expanded export patterns for (const dep of directNonBuildable) { - const packageName = - graph.nodes?.[dep.target]?.data?.metadata?.js?.packageName; + const node = graph.nodes?.[dep.target]; + const packageName = node?.data?.metadata?.js?.packageName; + if (packageName) { - allNonBuildable.add(packageName); + // Get exports from project metadata first (most reliable) + const packageExports = node?.data?.metadata?.js?.packageExports; + + if (packageExports) { + // Use metadata exports if available + const allowlistPatterns = createAllowlistFromExports( + packageName, + packageExports + ); + allowlistPatterns.forEach((pattern) => allNonBuildable.add(pattern)); + } else { + // Fallback: try to read package.json directly + try { + const projectRoot = node.data.root; + const packageJsonPath = join(projectRoot, 'package.json'); + const packageJson = readJsonFile(packageJsonPath); + const allowlistPatterns = createAllowlistFromExports( + packageName, + packageJson.exports + ); + allowlistPatterns.forEach((pattern) => allNonBuildable.add(pattern)); + } catch (error) { + // Final fallback: just add base package name + allNonBuildable.add(packageName); + } + } } // Get all transitive non-buildable dependencies App -> library1 -> library2