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 <noreply@anthropic.com>
This commit is contained in:
Colum Ferry 2025-06-09 13:58:57 +01:00 committed by GitHub
parent 659149d87c
commit f9c427a80b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 512 additions and 13 deletions

View File

@ -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']);
});
});

View File

@ -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, any> | 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<string>();
const allNonBuildable = new Set<string | RegExp>();
// First, find all direct non-buildable deps and add them App -> library
const directNonBuildable = deps.filter((dep) => {
@ -28,13 +97,39 @@ 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) {
// 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
const transitiveDeps = getAllTransitiveDeps(graph, dep.target);

View File

@ -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']);
});
});

View File

@ -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, any> | 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<string>();
const allNonBuildable = new Set<string | RegExp>();
// First, find all direct non-buildable deps and add them App -> library
const directNonBuildable = deps.filter((dep) => {
@ -136,13 +206,39 @@ 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) {
// 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
const transitiveDeps = getAllTransitiveDeps(graph, dep.target);