fix(monorepo): resolve ESM path aliases and project rootDir - #2444
Conversation
|
In the issue thread, an older upgraded project avoids this by moving the aliases to node subpath Keeping |
micalevisk
left a comment
There was a problem hiding this comment.
Thanks for the thorough investigation on the issue, the approach here is the right one and the two templates you touched work. There is one more template that still produces the exact error this PR fixes, so requesting changes for that.
I verified by generating CJS and ESM monorepos with this branch (application → sub-app → library), replacing the sources with an app that imports @app/shared, and running TypeScript 6.0.3 (the version the templates pin) against each project config:
| Project config | CJS | ESM |
|---|---|---|
Sub-app, before this PR (composite + rootDir: ./src) |
TS6059 + TS6307 | TS2307 |
Sub-app, this PR (rootDir: ../..) |
clean | clean |
| Library, this PR | clean | clean |
Original app moved by nest g app (workspace template, untouched) |
TS6059 + TS6307 | TS6059 + TS6307 |
Root tsc -b tsconfig.json, this PR |
fails only on the original app | fails only on the original app |
Findings
-
Blocking:
src/lib/sub-app/workspace/ts/tsconfig.app.jsonstill has the old config. It keepscomposite: trueandrootDir: "./src". That template is what the firstnest g appwrites for the original application when it moves it intoapps/<name>/. That app is the one most likely to import a library, and with this PR it still fails with TS6059/TS6307 in both CJS and ESM. It needs the same change as the other two templates. -
The new test cannot catch (1). The
project tsconfigtest only reads the sub-app's and the library's tsconfig. Please also assert on the original app's config (in the test env it lands at/apps/nestjs-schematics/tsconfig.app.json, since the name comes from this repo'spackage.json). -
The
start:prodscript written by the sub-app schematic is now wrong forbuilder: tsc.updateNpmScriptsinsub-app.factory.tsstill writesnode dist/apps/<app>/main, but withtscthe entry now emits todist/apps/<app>/apps/<app>/src/main.js. With rspack (the default the schematic sets) the layout is unchanged, since rspack ignoresrootDir, so the script stays correct there. Either adjust the script or state clearly that the layout change only applies tobuilder: tsc. -
Breaking-change scope is narrower than stated. Only
builder: tscsees a new output layout; rspack output stays atdist/apps/<app>/main.js. Worth saying so in the PR body / changelog. Also, "Docs have been added / updated" is ticked but the affected docs (node dist/apps/api/main) live innestjs/docs.nestjs.comand need their own PR.
Verified as working
- The ESM alias target
./libs/shared/src/index.tsresolves undernodenext; the CJS folder target keeps working. nest startstill finds the nested entry: nest-cli triesoutDir/sourceRoot/entryFilefirst and falls back tooutDir/entryFile.- Keeping
referencesin the roottsconfig.jsonafter droppingcompositedoes not raise TS6306 on TS 6;tsc -bat the root accepts the non-composite projects. - The dependency on nestjs/nest-cli#3545 is real: without it the emitted specifier lacks
index.jsand Node fails at runtime even thoughtscpasses. npm test: 42 files, 732 tests passing locally.
|
All four points are addressed:
Also verified the moved original app end-to-end: nest build nestjs-schematics exits 0, the emitted specifier is ../../../libs/shared/src/index.js, and it boots. |
micalevisk
left a comment
There was a problem hiding this comment.
Thanks! All four points from my previous review are addressed in e2e038d. I did a second pass focused on whether dropping composite: true is right, since it was added on purpose in v12.
Is dropping composite correct?
Yes. Going by the composite docs and the project references handbook, composite exists so tsc -b can find a referenced project's outputs. It does three things:
- It requires every implementation file to be matched by
include/files. That rule is exactly TS6307 here: once an app pullslibs/*/srcin throughpaths, its program contains files itsinclude: ["src/**/*"]does not match. So changingrootDiralone does not fix it. Withcomposite: true+rootDir: "../..", TS still reports TS6307 in both CJS and ESM. - It defaults
rootDirto the tsconfig directory. This no longer matters on TS 6, where "the defaultrootDirwill always be the directory containing thetsconfig.jsonfile". The explicitrootDir: "../.."is needed either way; without it you get TS5011. - It forces
declarationon. See the inline suggestion.
The handbook does say "referenced projects must have the new composite setting enabled". But tsc only enforces that (TS6306) when the referencing config has its own input files. The generated root tsconfig.json is solution-style ("files": [], which the handbook recommends), so references to non-composite projects are accepted. I checked that giving the root real inputs brings TS6306 back.
Nothing in the generated workspace uses what composite enables:
- Apps don't reference libs, so there is no
.d.tsredirection. - Libs are not referenced at all.
- The CLI's
tscbuilder uses an incremental program, not build mode.
composite came in with the v12 release (#2302), and I found no stated reason for it. Keeping it would mean one of two things:
- Widen every app's
includeto../../libs/*/src/**/*. That works, but it compiles every lib into every app and has to follow custom library roots. - Turn libs into real referenced projects built first with
tsc -b. That is a much bigger change, and it conflicts with bundlers resolving aliases to source.
Verification
Setup: TS 6.0.3, a generated application → sub-app → library workspace, both apps importing @app/shared.
| Check | master |
this PR |
|---|---|---|
tsc -p each app (incl. the moved original app), CJS |
TS6059 + TS6307 | clean |
tsc -p each app, ESM |
TS2307 | clean |
tsc -p lib, CJS/ESM |
clean | clean |
tsc -p tsconfig.json (solution root) |
clean | clean, no TS6306 |
tsc -b tsconfig.json, then again |
fails | clean; 2nd run reports "up to date" |
| tsserver: owning project / diagnostics per file | tsconfig.app.json, TS6059 + TS6307 |
tsconfig.app.json, none |
composite: true + rootDir: "../.." |
n/a | TS6307 |
Side effect worth mentioning: with rootDir: "../..", each project's .tsbuildinfo now lands inside its own outDir (dist/apps/admin/apps/admin/tsconfig.app.tsbuildinfo). On master, every app wrote to the same dist/apps/tsconfig.app.tsbuildinfo, outside its outDir, which is the nestjs/nest-cli#3408 situation.
npm test at e2e038d: 42 files, 734 tests passing.
Suggestions (none of them blocking)
declaration: falsefor app templates. Withcompositegone, apps no longer need declarations. See the inline suggestions.- Reuse the nest-cli config helpers in
readBuilder. See the inline comment. - Tighten the
start:prodregex. Small nit, inline.
Before merging / follow-ups
- Ship with nestjs/nest-cli#3545. That PR is still open. Without it, ESM builds pass but crash at runtime, so this should only be released together with it.
- Existing v12 projects. Workspaces already generated with 12.0.x keep
composite+rootDir: "./src"and need the same manual change. That deserves a release note, or an upgrade-schematic hint in a follow-up. - Docs. docs.nestjs.com needs its own PR for the nested
start:prodpath underbuilder: tsc.
Co-authored-by: Micael Levi L. Cavalcante <micalevisk@gmail.com>
Co-authored-by: Micael Levi L. Cavalcante <micalevisk@gmail.com>
Co-authored-by: Micael Levi L. Cavalcante <micalevisk@gmail.com>
`readBuilder()` walked every Nest CLI config file and took the first one that declared a `builder`. The CLI only loads the first file it finds, so a `.nestcli.json` builder could win over `nest-cli.json` and produce a wrong `start:prod` script. The config file list, `findNestCliConfigPath()` and `readJsonFile()` now live in `src/utils/nest-cli-config.util.ts` and are shared by `sub-app.factory.ts`, `library.factory.ts` and `upgrade.utils.ts` (re-exported so the existing `upgrade.utils.js` consumers keep working). Covered by a test.
2b29104 to
9f452c6
Compare
Pairing the project `rootDir` of `../..` with a per-project `outDir` of `../../dist/<type>/<name>` made every output path repeat the project segment: `dist/apps/api/apps/api/src/main.js`. The output path is `outDir` plus the source path relative to `rootDir`, and `rootDir` has to stay at the workspace root so a sibling project's sources resolved through a path alias are inside the program. Explicitly setting `rootDir` to `./src` instead brings back the `TS6059` this fixes, and omitting it is an error in TypeScript 6 (`TS5011`). Point `outDir` at `../../dist` instead. Projects then share the workspace `dist` - which is where the rspack builder already writes, since it sets no `output.path` - and the duplicated segment is gone: tsc dist/apps/api/src/main.js, dist/libs/shared/src/index.js rspack dist/apps/api/main.js `deleteOutDir` resolves to that shared root, so `nest g app` drops it when it converts a workspace to a monorepo; leaving it on would make `nest build api` wipe every other project's output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`swc` mirrors the source tree the way `tsc` does - `stripLeadingPaths`
is off whenever `rootDir` sits above the source root, which it now
always is - so it emits `dist/<root>/<app>/src/main.js` and was being
handed the flat bundler entry. Branch on whether the builder bundles
instead, and cover `webpack` alongside `rspack`.
Two smaller mismatches with the CLI on the same path: `builder` may be
a `{ type }` object rather than a name, and when it is absent the CLI
assumes `tsc`, not the `rspack` the conversion happens to write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `rootDir`, `outDir` and path-alias values only matter through what `tsc` does with them, and the failures they guard against - TS6059, TS6307, TS2307 on an alias - are invisible to a JSON assertion. Add a suite that writes a generated workspace to a temp directory, runs the real compiler over each project and captures the emitted paths, for both module systems. `moveDefaultAppToApps` is a no-op under NODE_ENV=test, so the suite performs the move itself; without it every check against the moved workspace app compiles an empty program and passes for the wrong reason. Also fills in the schematic-level matrix: the builder forms and fallbacks behind `start:prod`, `deleteOutDir` across repeated generations, and the alias shapes per module system. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Revert the shared workspace `dist` from the previous commit and put the
three project tsconfigs back to `outDir: ../../dist/<type>/<name>`,
with `deleteOutDir` left in place.
Run against the real CLI, the shared layout broke `nest start` under
the default builder. Bundlers write `dist/<root>/<entryFile>.js` no
matter what the tsconfig says, and `nest start` finds that file only
through its fallback probe, `<outDir>/<entryFile>` - which lines up with
the bundle solely when `outDir` is `dist/<root>`. With a shared `dist`
the probe became `dist/main.js` and the app failed to boot. A per-file
compiler mirrors the source tree under `rootDir` (the workspace root)
into that per-project `outDir`, so the nested
`dist/apps/<app>/apps/<app>/src/main.js` entry is forced by the two
constraints together, not a stray choice.
The builder handling from the previous commit stays: `swc` emits the
same nested layout as `tsc` (verified: `nest build` + `nest start` boot
from that path for tsc, swc and `{ type: 'swc' }`; rspack and webpack
boot from the flat bundle), `builder` may be a `{ type }` object, and
the CLI assumes `tsc` when it is absent.
The compile suite now pins both `nest start` probes against the
generated config, so the layout cannot drift from what the CLI looks
for.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PR Checklist
PR Type
What is the current behavior?
Issue Number: N/A (see nestjs/nest#17706)
nest g librarywrites an alias that points at a folder:Two things break on v12:
moduleResolution: "nodenext", TS does not look forindex.tsinside a folder, so the build fails withTS2307.composite: true+rootDir: "./src". Once the alias points at another project's source, TS reportsTS6059/TS6307. This also breaks CommonJS.What is the new behavior?
"@app/my-lib": ["./libs/my-lib/src/index.ts"]. CommonJS keeps the folder target.compositeand usesrootDir: "../.."— the sub-app, the library, and the workspace template used whennest g appmoves the original app.start:prodmatches the builder in use: the nestednode dist/apps/<app>/apps/<app>/src/mainfortsc, the flatnode dist/apps/<app>/mainfor bundlers.Needs nestjs/nest-cli#3545 in the same release. On its own, neither side fixes the issue.
Does this PR introduce a breaking change?
Only for
builder: tsc: the entry moves fromdist/apps/api/main.jstodist/apps/api/apps/api/src/main.js— the layout v11 produced undernest build, and the onenest startresolves automatically. Bundlers (rspack is the default) keep the flatdist/apps/api/main.jsbecause they ignorerootDir, so theirstart:prodis unchanged.Other information
Tested on generated v12 monorepos with #3545 applied and TypeScript 6.0.3:
nest build apiexits 0 and boots.nest g appbuilds and boots the same way.nest build libexits 0.npm test: 42 files, 734 tests passing.Alternative we considered
In the issue thread, an upgraded project avoids this by moving the aliases to node subpath imports —
importsinpackage.jsonpointing at the built output. That works too and keeps the output flat, but every library has to be built first, and it changes the generated import style and the workflow. Keepingpathspointing at source needs one build step and matches the v11 layout, so this PR stays with that.