Reproduction + experiments around how a Node app deploys workspace dependencies to Docker via @nx/js:copy-workspace-modules + @nx/esbuild:esbuild (TS-solution mode, bundle: false).
.
├── apps/api/ # @org/api - @nx/node app, esbuild bundle:false, HTTP server
├── packages/
│ ├── mylib/ # @org/mylib - buildable, tsc, exports -> ./dist/index.js
│ └── mylib-src/ # @org/mylib-src - non-buildable (bundler=none), exports -> ./src/index.ts
├── deployment/
│ ├── Dockerfile # node:24-alpine + corepack pnpm + pnpm install --prod
│ └── .dockerignore
├── verify-deploy.sh # litmus: build -> deploy -> docker run -> curl /health -> assert
└── run-experiment.sh # apply a mutation between prune and docker build, isolate load-bearing files
- Node.js (workspace was scaffolded with Node 22+, pnpm via corepack)
- pnpm 10+
- Docker (Desktop / OrbStack / Colima)
pnpm install
./verify-deploy.shExpected output:
==> deploy (build + prune + docker build)
...
==> hitting /health
{"status":"ok","mylib":"mylib","mylibSrc":"mylib-src","resolved":{"mylib":"/app/workspace_modules/@org/mylib/dist/index.js","mylibSrc":"/app/packages/mylib-src/src/index.js"}}
PASS
The resolved field in /health is the smoking gun: @org/mylib is loaded from workspace_modules/, @org/mylib-src is loaded from dist/packages/. Both copies exist in the deployed image, but each lib type only uses one.
npx create-nx-workspace@latest copy-workspace-modules-bug \
--preset=ts --packageManager=pnpm --nxCloud=skip --formatter=prettier
cd copy-workspace-modules-bug
pnpm add -D @nx/node @nx/js
pnpm exec nx g @nx/node:app api --linter=eslint --unitTestRunner=jest --no-interactive
pnpm exec nx g @nx/js:lib mylib --bundler=tsc --linter=eslint --unitTestRunner=jest --no-interactive
pnpm exec nx g @nx/js:lib mylib-src --bundler=none --linter=eslint --unitTestRunner=jest --no-interactive
pnpm exec nx g @nx/workspace:move --project=@org/api --destination=apps/api --no-interactive
pnpm exec nx g @nx/workspace:move --project=@org/mylib --destination=packages/mylib --no-interactive
pnpm exec nx g @nx/workspace:move --project=@org/mylib-src --destination=packages/mylib-src --no-interactiveManual edits after generation:
apps/api/package.json— added@org/mylib+@org/mylib-srcasworkspace:*deps; addeddeployandstart:deploytargets.apps/api/src/main.ts— replaced defaultconsole.logwith a tiny http server (/healthroute returning JSON with both libs called) and arequire.resolvelog of where each lib was loaded from.
@nx/node:app already wired up build (esbuild), prune-lockfile, copy-workspace-modules, and prune (noop, depends on the two prune steps).
After pnpm exec nx run @org/api:prune, look at apps/api/dist/:
-
apps/api/dist/main.js— the esbuild-emitted require-override shim. Contains amanifestmapping@org/<lib>to a path underdist/. The manifest is sourced fromtsconfig.base.json compilerOptions.paths; in TS-solution mode that means it covers non-buildable libs only — buildable libs are wired throughpackage.json exports+ project references, never throughpaths. -
apps/api/dist/workspace_modules/@org/{mylib,mylib-src}/— full project source trees for each workspace dep:src/,*.spec.ts,tsconfig*.json,eslint.config.mjs,jest.config.cts,.spec.swcrc,README.md. For the buildable lib it also hasdist/(becausetschappened to emit there before the copy). -
apps/api/dist/package.json(pruned) — has@org/mylib/@org/mylib-srcrewritten tofile:./workspace_modules/@org/.... This is what makes the deployment work: pnpm in the container resolves these via the file: protocol. -
apps/api/dist/packages/...— esbuild's parallel copy of compiled lib outputs. Used at runtime only for libs in the manifest (i.e. non-buildable). Buildable libs' copies here are dead bytes.
dist/main.js overrides Module._resolveFilename:
- Iterate the manifest. If an entry matches the request AND the candidate file under
dist/<exactMatch>exists → return that absolute path. Done. Standard Node resolution never runs. - Otherwise → fall through to original
Module._resolveFilename→ walksnode_modules/@org/<lib>(pnpm'sfile:symlink) → reads itspackage.json main/exports.
Confirmed by the require.resolve printout from /health:
[resolve] @org/mylib -> /app/workspace_modules/@org/mylib/dist/index.js
[resolve] @org/mylib-src -> /app/packages/mylib-src/src/index.js
| Lib type | esbuild copy dist/packages/<lib>/ |
workspace_modules/<lib>/ |
|---|---|---|
buildable (@org/mylib) |
dead | live (package.json + dist/) |
non-buildable (@org/mylib-src) |
live (manifest serves it) | dead (only package.json needed for pnpm install) |
./run-experiment.sh <name> "<bash mutation applied to deployment/app/>" stages apps/api/dist/ into deployment/app/, applies the mutation, builds the docker image, runs it, and prints PASS/FAIL.
| # | Experiment | Mutation | Result | Insight |
|---|---|---|---|---|
| 1 | baseline | (none) | PASS | sanity |
| 2 | delete-esbuild-copy | rm -rf packages |
FAIL | mylib-src cannot load from workspace_modules alone — .ts source with .js relative imports |
| 3 | del-pkg-mylib-src-only | rm -rf packages/mylib-src |
FAIL | non-buildable lib requires esbuild's compiled copy |
| 4 | del-pkg-mylib-only | rm -rf packages/mylib |
PASS | esbuild's copy of the buildable lib is dead weight |
| 5 | del-mylib-inner-dist | rm -rf workspace_modules/@org/mylib/dist |
FAIL | buildable lib's dist/ (sitting inside source copy) is what pnpm resolves |
| 6 | del-mylib-inner-src | rm -rf workspace_modules/@org/mylib/src |
PASS | source files inside the buildable lib's copy are dead weight |
| 7 | minimal-mylib | strip all but package.json + dist/ |
PASS | buildable copy only needs those two |
| 8 | mylib-src-pkgjson-only | strip all but package.json |
PASS | non-buildable copy only needs package.json for pnpm's file: link — runtime never reads inside it |
| 9 | ideal-prune | (#7) + (#8) | PASS | both libs trimmed to minimum; everything else removable |
| 10 | ideal-no-deadweight | (#9) + rm -rf packages/mylib |
PASS | also strip esbuild's copy of buildable lib |
Disk usage in the trivial repro: full deployment context 192K → trimmed 104K (~45% reduction). Scales with lib count + content.
- Buildable lib
@org/mylibworks becausetschappens to emitdist/inside the lib's project root, whichcopy-workspace-modulesthen drags along. If the build output lived anywhere else (esbuild bundler, customoutputPath), pnpm install couldn't resolve the lib. - Non-buildable lib
@org/mylib-srcworks because esbuild walks the project graph and emits compiled JS for it intodist/packages/<lib>/, AND embeds it in the manifest. If either of those steps changed, every non-buildable lib breaks.
Two undocumented invariants prop up the runtime.