Skip to content

Commit 59e2e51

Browse files
chore(devkit): build devkit to local dist and use nodenext (#34946)
## Current Behavior `@nx/devkit` builds to `<workspaceRoot>/dist/packages/devkit/` — outside its own package directory. Other packages reach into that shared workspace `dist/` to consume devkit, and consumers using `moduleResolution: nodenext` need a custom Module._resolveFilename hack to find it. This is inconsistent with `nx` itself, which already builds to `packages/nx/dist/` (#34111). ## Expected Behavior `@nx/devkit` builds to `packages/devkit/dist/` and is consumed via a standard `exports` map. Workspace consumers resolve through normal pnpm symlinks; the `@nx/nx-source` condition still lets in-repo code resolve to `.ts` source during dev. This unblocks the rest of the workspace from migrating in the same direction (a `dist-build-migration` Claude skill is included as a per-package playbook). ## How to review The PR has **307 files but only 3 categories of work**. Most of the diff is mechanical. ### 1. The actual migration — review carefully (~10 files) Devkit package config and the `@nx/nx-source` condition wiring: - `packages/devkit/package.json` — `exports` map, `typesVersions`, `files`, `type: commonjs`, `main`/`types` repointed to `dist/` - `packages/devkit/tsconfig.lib.json` — `outDir: dist`, `nodenext` module/resolution - `packages/devkit/project.json` — `build-base` outputs, `nx-release-publish.packageRoot`, `manifestRootsToUpdate` - `packages/devkit/internal.ts` — re-exports `src/utils/*` and `src/generators/*` symbols (replaces the `@nx/devkit/src/*` deep-import pattern) - `packages/devkit/eslint.config.mjs` — ignore `dist` - `packages/devkit/{README.md → readme-template.md}` — README is now generated, gitignored - `.gitignore` — ignore the generated `packages/devkit/README.md` - `scripts/nx-release.ts` — devkit's `package.json` now lives at `packages/devkit/package.json`, not `dist/packages/devkit/package.json` - `scripts/patched-jest-resolver.js` — adds `@nx/nx-source` condition ### 2. Mass import rewrites — already marked viewed (~205 files) Every internal import of `@nx/devkit/src/utils/<x>` or `@nx/devkit/src/generators/<x>` was rewritten to `@nx/devkit/internal`: ```diff -import { foo } from '@nx/devkit/src/utils/bar'; +import { foo } from '@nx/devkit/internal'; ``` I marked these files as **viewed** in the GitHub review UI to clear them from the unread queue. They're across nearly every plugin (angular, react, next, vite, webpack, rspack, expo, jest, etc.). ### 3. Follow-on cleanups (~10 files) These appear in their own commits and are easy to review individually: - **`cleanup(angular-rspack)`** — removes the `patchDevkitRequestPath` runtime hack from 14 example configs; devkit now resolves through standard node_modules (the MF patch stays — module-federation isn't migrated yet). - **`cleanup(devkit)` typedoc** — drops dead path mappings + redundant include manipulation in `astro-docs/src/plugins/utils/typedoc/typedoc.ts` (verified docs build is byte-identical with/without the removed config). - **`cleanup(devkit)` eslint ignores** — drops `'**/*.d.ts'` from `packages/devkit/eslint.config.mjs` since `.d.ts` files only emit to `dist/` (already ignored). - **`fix(angular)` eslint quote** — quote-agnostic regex in `e2e/angular/src/projects-linting.test.ts` so the test still disables `prefer-standalone` after the angular-eslint generator switched to double quotes (latent bug surfaced by CI). - **`fix(testing)` jest migration** — removes a stray unused `@nx/devkit/internal` import. - **e2e test fallout** — `e2e/{angular,next}/src/*.test.ts` lose access to `@nx/devkit/src/utils/string-utils` (e2e tests can't use `/internal`); inline equivalents using public `names()` API. `e2e/nx-build/src/nx-build.test.ts` updates the expected output path. ## Local verification - `pnpm nx run-many -t test,build,lint -p devkit` ✓ - `pnpm nx run astro-docs:build` ✓ — devkit reference pages still generate (148 markdown files; identical to a build with the path mappings re-added as a control) - `pnpm nx run-many -t build -p examples-angular-rspack-csr-css,examples-angular-rspack-ssr-css,examples-angular-rspack-zoneless-csr-css,examples-angular-rspack-mf-host,examples-angular-rspack-mf-remote --skip-nx-cache` ✓ — examples build without the devkit patch ## Known follow-up (not in this PR) - `scripts/nx-release.ts` still has `hackFixForDevkitPeerDependencies()` (a band-aid from #32406 that re-adds `<=` to devkit's `nx` peer-dep range after `nx release version` strips it). The proper fix is to set `preserveMatchingDependencyRanges: true` in `packages/devkit/project.json` and delete the hack — that needs a release dry-run to verify, so it's queued separately. ## Related Issue(s) Follow-up to #34111. --------- Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com>
1 parent c4ca481 commit 59e2e51

307 files changed

Lines changed: 908 additions & 606 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
---
2+
name: dist-build-migration
3+
description: Migrate an Nx package to build to a local dist/ directory with nodenext module resolution, exports map, and @nx/nx-source condition.
4+
allowed-tools: Bash, Read, Glob, Grep, Agent, Edit, Write
5+
---
6+
7+
# Migrate Package to Local Dist Build
8+
9+
You are migrating an Nx monorepo package from building to `../../dist/packages/<name>` to building locally to `packages/<name>/dist/`. This matches the pattern already used by `nx` and `devkit`.
10+
11+
## Argument
12+
13+
The user provides a package name (e.g., `js`, `webpack`, `angular`). The package lives at `packages/<name>/`.
14+
15+
## Steps
16+
17+
### 1. Read current state
18+
19+
Read these files for the target package:
20+
21+
- `packages/<name>/package.json`
22+
- `packages/<name>/project.json`
23+
- `packages/<name>/tsconfig.lib.json`
24+
- `packages/<name>/tsconfig.spec.json` (if exists)
25+
- `packages/<name>/.eslintrc.json` (if exists)
26+
- `packages/<name>/assets.json` (if exists)
27+
- `packages/<name>/.npmignore` (if exists)
28+
- `packages/<name>/.gitignore` (if exists)
29+
30+
Also read the reference implementations:
31+
32+
- `packages/devkit/tsconfig.lib.json`
33+
- `packages/devkit/package.json`
34+
- `packages/devkit/project.json`
35+
- `packages/devkit/.npmignore`
36+
37+
Run `pnpm nx show target <name>:build-base` to see the inferred build target.
38+
Run `pnpm nx show target <name>:build` to see the full build target.
39+
40+
### 2. Identify entry points
41+
42+
Look at the package's root `.ts` files and any existing `exports` field. Common entry points:
43+
44+
- `index.ts` (main)
45+
- `testing.ts`
46+
- `internal.ts`
47+
- `ngcli-adapter.ts`
48+
- Any other `.ts` files at the package root that re-export from `src/`
49+
50+
Also check for `migrations.json` and `generators.json`/`executors.json` — these need exports entries too.
51+
52+
### 3. Update `tsconfig.lib.json`
53+
54+
Transform from the old pattern to the new pattern:
55+
56+
**Before:**
57+
58+
```json
59+
{
60+
"compilerOptions": {
61+
"module": "commonjs",
62+
"outDir": "../../dist/packages/<name>",
63+
"tsBuildInfoFile": "../../dist/packages/<name>/tsconfig.tsbuildinfo"
64+
}
65+
}
66+
```
67+
68+
**After:**
69+
70+
```json
71+
{
72+
"compilerOptions": {
73+
"outDir": "dist",
74+
"rootDir": ".",
75+
"declarationDir": "dist",
76+
"declarationMap": false,
77+
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
78+
"types": ["node"],
79+
"composite": true,
80+
"module": "nodenext",
81+
"moduleResolution": "nodenext",
82+
"esModuleInterop": true,
83+
"allowSyntheticDefaultImports": true
84+
},
85+
"exclude": ["node_modules", "dist", ...existing excludes, ".eslintrc.json"],
86+
"include": ["*.ts", "src/**/*.ts"]
87+
}
88+
```
89+
90+
**Important**: Adjust `include` based on the package's actual structure. If the package has directories like `bin/`, `plugins/`, etc. at the root level (like `nx` does), include those too.
91+
92+
### 4. Update `tsconfig.spec.json` (if exists)
93+
94+
Change `outDir` from `../../dist/packages/<name>/spec` to `dist/spec`.
95+
96+
### 5. Update `package.json`
97+
98+
Key changes:
99+
100+
- Add `"type": "commonjs"` near the top (after `private`)
101+
- Change `"main"` to `"./dist/index.js"`
102+
- Change `"types"` to `"./dist/index.d.ts"`
103+
- Add `"typesVersions"` for backwards compatibility with `moduleResolution: "node"` consumers
104+
- Add `"exports"` map with entries for each entry point
105+
106+
Each export entry follows this pattern:
107+
108+
```json
109+
"./entry-name": {
110+
"@nx/nx-source": "./entry-name.ts",
111+
"types": "./entry-name.d.ts",
112+
"default": "./dist/entry-name.js"
113+
}
114+
```
115+
116+
The main entry (`.`) uses `./index.ts`, `./index.d.ts`, `./dist/index.js`.
117+
118+
Always include:
119+
120+
```json
121+
"./package.json": "./package.json"
122+
```
123+
124+
Include `"./migrations.json": "./migrations.json"` if the package has migrations.
125+
126+
**Note**: The `@nx/nx-source` condition is a custom condition used for source-level resolution within the workspace (so other packages import from source, not dist).
127+
128+
Add a `typesVersions` field for consumers using `moduleResolution: "node"` (which doesn't read `exports`):
129+
130+
```json
131+
"typesVersions": {
132+
"*": {
133+
"testing": ["dist/testing.d.ts"],
134+
"ngcli-adapter": ["dist/ngcli-adapter.d.ts"]
135+
}
136+
}
137+
```
138+
139+
Add an entry for each subpath export (excluding `.`, `./package.json`, and `./migrations.json`).
140+
141+
### 6. Update `project.json`
142+
143+
Add these sections:
144+
145+
```json
146+
{
147+
"release": {
148+
"version": {
149+
"generator": "@nx/js:release-version",
150+
"preserveLocalDependencyProtocols": true,
151+
"manifestRootsToUpdate": ["packages/{projectName}"]
152+
}
153+
},
154+
"targets": {
155+
"nx-release-publish": {
156+
"options": {
157+
"packageRoot": "packages/{projectName}"
158+
}
159+
},
160+
"build-base": {
161+
"outputs": [
162+
"{projectRoot}/dist/**/*.{js,cjs,mjs,d.ts}",
163+
"{projectRoot}/*.d.ts",
164+
"{projectRoot}/src/**/*.d.ts"
165+
]
166+
}
167+
}
168+
}
169+
```
170+
171+
Update the existing `build` target's `outputs` if they reference `{workspaceRoot}/dist/packages/<name>` — they should now reference `{projectRoot}/dist/`.
172+
173+
Also update `dependsOn` in the `build` target: replace `"^build"` with `"^build"` if it isn't already, and make sure `"build-base"` is listed.
174+
175+
### 7. Update `.eslintrc.json`
176+
177+
Add `"dist"` and `"*.d.ts"` to `ignorePatterns`:
178+
179+
```json
180+
"ignorePatterns": ["!**/*", "node_modules", "dist", "*.d.ts"]
181+
```
182+
183+
### 8. Update `assets.json` (if exists)
184+
185+
Change `outDir` from `"dist/packages/<name>"` to `"packages/<name>/dist"`.
186+
187+
### 9. Add `files` field to `package.json`
188+
189+
Instead of using `.npmignore`, add a `"files"` field to `package.json` (matching the `nx` package pattern). Remove `.npmignore` if it exists.
190+
191+
```json
192+
"files": [
193+
"dist",
194+
"!dist/tsconfig.tsbuildinfo",
195+
"migrations.json"
196+
]
197+
```
198+
199+
Adjust based on the package's needs:
200+
201+
- Add `"executors.json"` and/or `"generators.json"` if the package has them
202+
- Add any other non-TS files that need to be published
203+
- npm always includes `package.json` and `README.md` automatically — no need to list them
204+
205+
### 10. Rename README.md and update build command
206+
207+
If the package has a `README.md` at its root and uses the `copy-readme.js` script in its build target:
208+
209+
1. Rename `README.md` to `readme-template.md` (`git mv`)
210+
2. Update the build command to pass explicit paths:
211+
```
212+
node ./scripts/copy-readme.js <name> packages/<name>/readme-template.md packages/<name>/README.md
213+
```
214+
3. Update the build target `outputs` to `["{projectRoot}/README.md"]`
215+
216+
The script's default behavior reads `packages/<name>/README.md` and writes to `dist/packages/<name>/README.md` — both wrong for the new layout. Passing explicit args fixes both.
217+
218+
### 11. Update root `.gitignore`
219+
220+
Add two entries to the workspace root `.gitignore`:
221+
222+
1. Under the section that lists generated README files (look for `packages/nx/README.md`), add:
223+
224+
```
225+
packages/<name>/README.md
226+
```
227+
228+
2. Under the section that lists generated `.d.ts` files (look for `packages/nx/**/*.d.ts`), add:
229+
```
230+
packages/<name>/**/*.d.ts
231+
```
232+
233+
These are build outputs that shouldn't be committed.
234+
235+
### 12. Update docs generation paths
236+
237+
Check `astro-docs/src/plugins/utils/` for any code that references `.d.ts` files from the package. The docs generation reads `.d.ts` entry points to build API reference pages. Paths that previously pointed to `dist/packages/<name>/foo.d.ts` (workspace root dist) or `packages/<name>/foo.d.ts` (package root) now need to point to `packages/<name>/dist/foo.d.ts`.
238+
239+
For example, `devkit-generation.ts` had to be updated to look for `packages/devkit/dist/index.d.ts` instead of `packages/devkit/index.d.ts`.
240+
241+
### 13. Update `scripts/nx-release.ts`
242+
243+
If the package has special release handling in `scripts/nx-release.ts` (like devkit's `hackFixForDevkitPeerDependencies`), update any paths from `./dist/packages/<name>/` to `./packages/<name>/`.
244+
245+
### 14. Update imports across the workspace
246+
247+
Search for imports from `@nx/<name>/src/` across all other packages. These internal imports need to be updated:
248+
249+
- If the imported thing is re-exported through a public entry point (index.ts, internal.ts, etc.), update the import to use that entry point
250+
- If not, consider adding it to `internal.ts` or the appropriate entry point
251+
252+
Use: `grep -r "from '@nx/<name>/src/" packages/ --include="*.ts" -l` to find affected files.
253+
254+
Also check for imports in:
255+
256+
- `e2e/` tests
257+
- `scripts/`
258+
- `tools/workspace-plugin/`
259+
- `astro-docs/`
260+
- `examples/`
261+
262+
### 15. Verify
263+
264+
Run:
265+
266+
```bash
267+
pnpm nx run-many -t test,build,lint -p <name>
268+
```
269+
270+
Then:
271+
272+
```bash
273+
pnpm nx affected -t build,test,lint
274+
```
275+
276+
### Summary of the pattern
277+
278+
The core idea is simple: instead of building to a shared `dist/packages/<name>/` at the workspace root, each package builds to its own `packages/<name>/dist/`. The `exports` map with `@nx/nx-source` condition lets workspace packages resolve to `.ts` source files during development, while external consumers get the built `.js` from `dist/`. This is like giving each package its own "output mailbox" instead of sharing one big mailbox.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ packages/angular-rspack-compiler/README.md
133133
packages/dotnet/README.md
134134
packages/maven/README.md
135135
packages/nx/README.md
136+
packages/devkit/README.md
136137

137138
test-output
138139
test-results

astro-docs/src/plugins/utils/devkit-generation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ export async function loadDevkitPackage(
2424
// generate main @nx/devkit docs
2525
const devkitEntryPoint = join(
2626
workspaceRoot,
27-
'dist',
2827
'packages',
2928
'devkit',
29+
'dist',
3030
'index.d.ts'
3131
);
3232
if (existsSync(devkitEntryPoint)) {
@@ -47,9 +47,9 @@ export async function loadDevkitPackage(
4747
// generate ngcli docs in same dir
4848
const ngcliEntryPoint = join(
4949
workspaceRoot,
50-
'dist',
5150
'packages',
5251
'devkit',
52+
'dist',
5353
'ngcli-adapter.d.ts'
5454
);
5555
if (existsSync(ngcliEntryPoint)) {

astro-docs/src/plugins/utils/typedoc/typedoc.ts

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,12 @@ export function setupTypeDoc(logger: LoaderContext['logger']) {
7575
join(projectRoot, 'node_modules', '@types'),
7676
];
7777

78-
// This ensures that nx and @nx/<plugin> modules resolve to `dist` rather than what's installed in node_modules.
79-
// TODO(jack,caleb): If we move outDir from `dist/packages/nx` to `packages/nx/dist` like standard TS solution setup,
80-
// then this isn't needed anymore since we should have devDependencies that resolve to local
81-
// `node_modules` not the root one.
82-
tsconfigObj.compilerOptions.baseUrl = workspaceRoot;
83-
tsconfigObj.compilerOptions.paths = {
84-
'nx/*': ['dist/packages/nx/*', 'packages/nx/src/*'],
85-
'@nx/*': ['dist/packages/*', 'packages/*/src/*'],
86-
};
78+
// TypeDoc requires its entry points to be referenced by `include` or
79+
// `files` in the tsconfig. Point at devkit's compiled .d.ts files.
80+
tsconfigObj.include = [join(devkitPath, 'dist', '**', '*.d.ts')];
8781

8882
tsconfigObj.exclude = [
89-
...(tsconfigObj.exclude || []),
83+
...(tsconfigObj.exclude || []).filter((e: string) => e !== 'dist'),
9084
'**/*.spec.ts',
9185
'**/*.test.ts',
9286
'**/test/**',
@@ -95,16 +89,6 @@ export function setupTypeDoc(logger: LoaderContext['logger']) {
9589
'node_modules/@types/jest/**',
9690
];
9791

98-
// The tsconfig now lives in tempDir but it operates on devkit's compiled
99-
// dist (entry point is dist/packages/devkit/index.d.ts). Resolve include
100-
// patterns to absolute paths anchored at the dist directory so TypeDoc
101-
// picks up the .d.ts files instead of looking for sources next to the temp
102-
// tsconfig.
103-
const distDevkitDir = join(workspaceRoot, 'dist', 'packages', 'devkit');
104-
tsconfigObj.include = (tsconfigObj.include || ['**/*.ts']).map(
105-
(pattern: string) => join(distDevkitDir, pattern)
106-
);
107-
10892
writeFileSync(generatedTsconfigPath, JSON.stringify(tsconfigObj, null, 2));
10993

11094
rmSync(outDir, { recursive: true, force: true });

e2e/angular/src/misc.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
uniq,
88
updateFile,
99
} from '@nx/e2e-utils';
10-
import { classify } from '@nx/devkit/src/utils/string-utils';
10+
import { names } from '@nx/devkit';
11+
const classify = (s: string) => names(s).className;
1112

1213
describe('Move Angular Project', () => {
1314
let proj: string;

e2e/angular/src/projects-linting.test.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,17 @@ describe('Angular Projects - Linting', () => {
2323
it('should lint correctly with eslint and handle external HTML files and inline templates', async () => {
2424
const { app1, lib1 } = setup;
2525

26-
// disable the prefer-standalone rule for app1 which is not standalone
27-
let app1EslintConfig = readFile(`${app1}/eslint.config.mjs`);
28-
app1EslintConfig = app1EslintConfig.replace(
29-
`'@angular-eslint/directive-selector': [`,
30-
`'@angular-eslint/prefer-standalone': 'off',
31-
'@angular-eslint/directive-selector': [`
32-
);
33-
updateFile(`${app1}/eslint.config.mjs`, app1EslintConfig);
26+
// disable the prefer-standalone rule for app1 and lib1 which are not standalone.
27+
// Use a regex so we match regardless of whether the generated config uses
28+
// single or double quotes around the rule name.
29+
for (const project of [app1, lib1]) {
30+
let eslintConfig = readFile(`${project}/eslint.config.mjs`);
31+
eslintConfig = eslintConfig.replace(
32+
/(['"])@angular-eslint\/directive-selector\1:\s*\[/,
33+
`"@angular-eslint/prefer-standalone": "off",\n "@angular-eslint/directive-selector": [`
34+
);
35+
updateFile(`${project}/eslint.config.mjs`, eslintConfig);
36+
}
3437

3538
// check apps and lib pass linting for initial generated code
3639
runCLI(`run-many --target lint --projects=${app1},${lib1} --parallel`);

0 commit comments

Comments
 (0)