Description
When a route or package does a namespaced import of an internal package that is not an installed npm/workspace dependency — e.g. one resolved via tsconfig paths — @wordpress/build crashes instead of falling through to esbuild's own resolution:
✘ [plugin: wordpress-externals] Cannot find module '@my-plugin/init/package.json'
The externals plugin already contains a guard that is clearly meant to handle this case gracefully, but the guard is dead code: getPackageInfo() throws before the guard can ever run, even though its JSDoc promises it returns null on a miss.
(Source references below are against @wordpress/build@0.14.0; line numbers may drift across versions.)
Root cause
getPackageInfo() calls require.resolve() with no try/catch, so a missing package raises instead of returning null:
// packages/wp-build/lib/package-utils.mjs (~L76–95)
/**
* @return {PackageJson|null} Package.json object or null if not found.
*/
export function getPackageInfo( fullPackageName, resolveDir = null ) {
// ...
const require = createRequire( contextPath );
const resolved = require.resolve( `${ fullPackageName }/package.json` ); // ← throws on miss
const result = getPackageInfoFromFile( resolved );
packageJsonCache.set( cacheKey, result );
return result;
}
The function's documented contract is @return {PackageJson|null}, but it can never return null — it either returns an object or throws.
The externals plugin relies on the documented null contract:
// packages/wp-build/lib/wordpress-externals-plugin.mjs (~L220–227)
const packageJson = getPackageInfo( packageName, args.resolveDir );
if ( ! packageJson ) {
return undefined; // intended: fall through to esbuild's own resolution — but unreachable
}
Because getPackageInfo throws first, the exception propagates out of the onResolve callback and esbuild reports it as a plugin error → the build fails.
Why only this call site crashes
getPackageInfo has two callers, and they behave asymmetrically:
| Caller |
Guarded? |
Behaviour today |
build.mjs (inferStyleDependencies, ~L949–975) |
Wrapped in try { … } catch { continue } and has an if ( ! depPackageJson ) guard |
Safe — the throw is swallowed |
wordpress-externals-plugin.mjs (~L220) |
No try/catch; relies on the (dead) null guard |
Build crashes |
So the bug is really that getPackageInfo violates its own contract; the externals plugin is just the unprotected caller that exposes it.
Step-by-step reproduction
- In a project built with
@wordpress/build, add a tsconfig.json with a paths entry mapping a namespaced specifier to a local source directory, e.g. "@my-plugin/init": ["./src/init"], where @my-plugin/init is not installed in node_modules.
- From a route/entry,
import { init } from '@my-plugin/init';.
- Run the build.
- Expected: the guard returns
undefined, esbuild resolves the specifier via its automatic tsconfig paths discovery, and the module is bundled inline (the same treatment already given to any found-but-non-wpScript package — see wordpress-externals-plugin.mjs ~L279).
- Actual:
✘ [plugin: wordpress-externals] Cannot find module '@my-plugin/init/package.json' and the build fails.
Proposed fix
Make getPackageInfo() honor its |null contract by catching the resolution failure. This activates the existing guards in both callers as intended, with no behaviour change for installed packages. Also cache the negative result so repeated misses don't re-trigger a throwing require.resolve:
let resolved;
try {
resolved = require.resolve( `${ fullPackageName }/package.json` );
} catch {
packageJsonCache.set( cacheKey, null );
return null;
}
const result = getPackageInfoFromFile( resolved );
packageJsonCache.set( cacheKey, result );
return result;
(The early-return at the top of getPackageInfo uses packageJsonCache.has( cacheKey ), so a cached null is returned correctly on subsequent hits.)
Notes:
- Inline-bundling the fallen-through package is consistent with existing behaviour: a package that's found but has neither
wpScript nor wpScriptModuleExports already returns undefined and gets bundled inline (~L279).
- No explicit
tsconfig needs to be passed to esbuild — esbuild auto-discovers the nearest tsconfig.json for paths, which is what makes the fall-through resolve.
Tests
The package currently has no test coverage, so this would add:
- Unit:
getPackageInfo( '@does-not-exist/nope', someDir ) returns null (does not throw); a repeated call returns the cached null without re-throwing.
- Integration (optional): a fixture project importing a namespaced specifier that is unresolvable as an npm dependency but mapped via
tsconfig paths; assert the build succeeds and bundles the module inline.
Related
Description
When a route or package does a namespaced import of an internal package that is not an installed npm/workspace dependency — e.g. one resolved via
tsconfigpaths—@wordpress/buildcrashes instead of falling through to esbuild's own resolution:The externals plugin already contains a guard that is clearly meant to handle this case gracefully, but the guard is dead code:
getPackageInfo()throws before the guard can ever run, even though its JSDoc promises it returnsnullon a miss.(Source references below are against
@wordpress/build@0.14.0; line numbers may drift across versions.)Root cause
getPackageInfo()callsrequire.resolve()with notry/catch, so a missing package raises instead of returningnull:The function's documented contract is
@return {PackageJson|null}, but it can never returnnull— it either returns an object or throws.The externals plugin relies on the documented
nullcontract:Because
getPackageInfothrows first, the exception propagates out of theonResolvecallback and esbuild reports it as a plugin error → the build fails.Why only this call site crashes
getPackageInfohas two callers, and they behave asymmetrically:build.mjs(inferStyleDependencies, ~L949–975)try { … } catch { continue }and has anif ( ! depPackageJson )guardwordpress-externals-plugin.mjs(~L220)try/catch; relies on the (dead)nullguardSo the bug is really that
getPackageInfoviolates its own contract; the externals plugin is just the unprotected caller that exposes it.Step-by-step reproduction
@wordpress/build, add atsconfig.jsonwith apathsentry mapping a namespaced specifier to a local source directory, e.g."@my-plugin/init": ["./src/init"], where@my-plugin/initis not installed innode_modules.import { init } from '@my-plugin/init';.undefined, esbuild resolves the specifier via its automatictsconfigpathsdiscovery, and the module is bundled inline (the same treatment already given to any found-but-non-wpScriptpackage — seewordpress-externals-plugin.mjs~L279).✘ [plugin: wordpress-externals] Cannot find module '@my-plugin/init/package.json'and the build fails.Proposed fix
Make
getPackageInfo()honor its|nullcontract by catching the resolution failure. This activates the existing guards in both callers as intended, with no behaviour change for installed packages. Also cache the negative result so repeated misses don't re-trigger a throwingrequire.resolve:(The early-return at the top of
getPackageInfousespackageJsonCache.has( cacheKey ), so a cachednullis returned correctly on subsequent hits.)Notes:
wpScriptnorwpScriptModuleExportsalready returnsundefinedand gets bundled inline (~L279).tsconfigneeds to be passed to esbuild — esbuild auto-discovers the nearesttsconfig.jsonforpaths, which is what makes the fall-through resolve.Tests
The package currently has no test coverage, so this would add:
getPackageInfo( '@does-not-exist/nope', someDir )returnsnull(does not throw); a repeated call returns the cachednullwithout re-throwing.tsconfigpaths; assert the build succeeds and bundles the module inline.Related
wpModule/wpScriptModuleExportspackages outside./packages/*#77225 — monorepo discovery of sharedwpModule/wpScriptModuleExportspackages outsidepackages/*. Adjacent concern (discovery/compilation of shared packages) vs. this issue (graceful fall-through when a namespaced import isn't an installed dep). Both touch wp-build's package resolution path.