Skip to content

nrwl/nx-node-monorepo-deploy-tests

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

nx-node-monorepo-deploy-tests

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).

Layout

.
├── 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

Prerequisites

  • Node.js (workspace was scaffolded with Node 22+, pnpm via corepack)
  • pnpm 10+
  • Docker (Desktop / OrbStack / Colima)

Quick start

pnpm install
./verify-deploy.sh

Expected 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.

How the workspace was created (Nx generators only)

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-interactive

Manual edits after generation:

  • apps/api/package.json — added @org/mylib + @org/mylib-src as workspace:* deps; added deploy and start:deploy targets.
  • apps/api/src/main.ts — replaced default console.log with a tiny http server (/health route returning JSON with both libs called) and a require.resolve log 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).

What the deployment produces

After pnpm exec nx run @org/api:prune, look at apps/api/dist/:

  1. apps/api/dist/main.js — the esbuild-emitted require-override shim. Contains a manifest mapping @org/<lib> to a path under dist/. The manifest is sourced from tsconfig.base.json compilerOptions.paths; in TS-solution mode that means it covers non-buildable libs only — buildable libs are wired through package.json exports + project references, never through paths.

  2. 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 has dist/ (because tsc happened to emit there before the copy).

  3. apps/api/dist/package.json (pruned) — has @org/mylib/@org/mylib-src rewritten to file:./workspace_modules/@org/.... This is what makes the deployment work: pnpm in the container resolves these via the file: protocol.

  4. 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.

Runtime resolution

dist/main.js overrides Module._resolveFilename:

  1. 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.
  2. Otherwise → fall through to original Module._resolveFilename → walks node_modules/@org/<lib> (pnpm's file: symlink) → reads its package.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)

Experiments

./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.

Why the litmus passes today (for shaky reasons)

  • Buildable lib @org/mylib works because tsc happens to emit dist/ inside the lib's project root, which copy-workspace-modules then drags along. If the build output lived anywhere else (esbuild bundler, custom outputPath), pnpm install couldn't resolve the lib.
  • Non-buildable lib @org/mylib-src works because esbuild walks the project graph and emits compiled JS for it into dist/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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors