(aws-lambda-nodejs): PackageInstallation.detect() fails to find esbuild in npm workspaces when not hoisted to root
- Dominant language
- TypeScript
- Stars
- 12.9k
- Forks
- 4.6k
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 83
Description
### Describe the bug
`PackageInstallation.detect("esbuild")` uses `require("esbuild/package.json")` to detect whether esbuild is installed locally. Because `require()` resolves from the calling module's location (`node_modules/aws-cdk-lib/aws-lambda-nodejs/lib/util.js`), it can only find esbuild at the root `node_modules/esbuild/`. It cannot find esbuild installed in workspace-local `node_modules/` directories (e.g., `packages/my-app/node_modules/esbuild/`).
This means `NodejsFunction` silently falls back to Docker-based bundling in npm workspace monorepos whenever esbuild is not hoisted to root — even though esbuild is installed and fully functional.
Whether esbuild ends up at root or in workspace-local dirs depends on npm's hoisting algorithm, which is influenced by other packages in the dependency tree. For example, if another root-level dependency happens to also depend on esbuild with a compatible semver range, npm will hoist it. If that transitive dependency is removed or its semver range becomes incompatible (as happens with `0.x` packages where `^0.27.0` excludes `0.28.0`), npm stops hoisting and detection breaks — with no code changes on the user's part.
**Impact:** In CI environments, the silent Docker fallback often fails with architecture mismatches (`exec format error` on ARM runners pulling x86 images), causing cryptic build failures that are difficult to diagnose since the root cause (esbuild not detected) is hidden behind the fallback behavior.
### Regression Issue
- [x] Select this option if this issue appears to be a regression.
### Last Known Working CDK Library Version
2.245.0
### Expected Behavior
`PackageInstallation.detect("esbuild")` should find esbuild when it's installed in the project, regardless of whether npm hoisted it to root or placed it in a workspace-local `node_modules/` directory.
### Current Behavior
Detection fails when esbuild is in a workspace-local `node_modules/` and not at root. CDK prints `esbuild cannot run locally. Switching to Docker bundling.` and falls back to container-based builds, even though esbuild is installed and the binary works.
### Reproduction Steps
```bash
mkdir repro && cd repro
# Create monorepo structure
cat > package.json << 'INNER'
{
"name": "repro",
"private": true,
"workspaces": ["packages/*"]
}
INNER
mkdir -p packages/my-app
cat > packages/my-app/package.json << 'INNER'
{
"name": "my-app",
"dependencies": {
"aws-cdk-lib": "^2.248.0",
"constructs": "^10.0.0",
"esbuild": "^0.28.0"
}
}
INNER
npm install
# esbuild is in packages/my-app/node_modules/esbuild, NOT at root
ls node_modules/esbuild 2>&1 || echo "Not at root"
ls packages/my-app/node_modules/esbuild
# CDK's detection fails:
node -e "
const Module = require('module');
const path = require('path');
const cdkRequire = Module.createRequire(
path.join(process.cwd(), 'node_modules/aws-cdk-lib/aws-lambda-nodejs/lib/util.js')
);
try {
console.log('Found:', cdkRequire('esbuild/package.json').version);
} catch(e) {
console.log('Detection FAILED:', e.code);
}
"
```
### Possible Solution
Change `tryGetModuleVersionFromRequire` in `util.ts` to resolve esbuild relative to `process.cwd()` instead of from CDK's own module location. For example:
```typescript
export function tryGetModuleVersionFromRequire(mod: string): string | undefined {
try {
const req = Module.createRequire(path.join(process.cwd(), 'package.json'));
return req(`${mod}/package.json`).version;
} catch (err) {
return undefined;
}
}
```
This would find esbuild in workspace-local `node_modules/` directories by starting resolution from the project root where `cdk synth` is invoked.
### Additional Information/Context
This affects any npm workspace monorepo where esbuild is declared as a dependency in specific workspaces rather than at the root. The current workaround is adding esbuild as a `devDependency` in the root `package.json`, but this forces a project-wide dependency that may only be needed by specific workspaces.
We discovered this after Vite 8 moved esbuild from `dependencies` to an optional `peerDependencies` (as part of their switch to Rolldown), which removed esbuild as a transitive root-level dependency in our monorepo. However, the underlying issue affects any monorepo where npm's hoisting algorithm places esbuild in workspace-local dirs rather than root — which can happen for various reasons including semver range conflicts between packages.
Note: PR #37292 (merged Mar 25, 2026) improved how CDK *executes* esbuild (direct spawn vs shell), but did not change how CDK *detects* esbuild — `PackageInstallation.detect()` still uses the same `require()` call.
See aws-powertools/powertools-lambda-typescript#5172 for more context and a setup that exhibits the issue [at this commit sha](https://github.com/aws-powertools/powertools-lambda-typescript/tree/364411bdbbbfea428b27f399953486ad1553a3c1).
### AWS CDK Library version (aws-cdk-lib)
2.248.0
### AWS CDK CLI version
2.1113.0
### Node.js Version
v24.11.1
### OS
macOS / Ubuntu (reproducible on both)
### Language
- [x] TypeScript
### Other information
_No response_
Contributor guide
Research direction
Start in util.ts at tryGetModuleVersionFromRequire and trace how PackageInstallation.detect() resolves esbuild. Reproduce the npm workspace layout from the issue, then run the relevant aws-lambda-nodejs tests or CDK bundling flow. Done means locally installed workspace esbuild is detected and NodejsFunction no longer falls back to Docker.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- build-system, tooling
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100