Skip to content

Commit 3a41c61

Browse files
Merge pull request #2444 from xia-chao/fix/monorepo-alias-esm
fix(monorepo): resolve ESM path aliases and project rootDir
2 parents 1e74cd5 + 2a4b6f9 commit 3a41c61

10 files changed

Lines changed: 1117 additions & 58 deletions

File tree

‎src/lib/library/files/ts/tsconfig.lib.json‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
{
22
"extends": "../../tsconfig.json",
33
"compilerOptions": {
4-
"composite": true,
54
"declaration": true,
6-
"rootDir": "./src",
5+
"rootDir": "../..",
76
"outDir": "../../dist/libs/<%= name %>"
87
},
98
"include": ["src/**/*"],

‎src/lib/library/library.factory.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { formatFiles } from '../../utils/format-files.rule.js';
1919
import {
2020
createModuleNameMapper,
2121
inPlaceSortByKeys,
22+
NEST_CLI_CONFIG_FILES,
2223
normalizeToKebabOrSnakeCase,
2324
} from '../../utils/index.js';
2425
import {
@@ -59,12 +60,9 @@ export function main(options: LibraryOptions): Rule {
5960

6061
function getDefaultLibraryPrefix(defaultLibraryPrefix = '@app') {
6162
const fileSystemReader = new FileSystemReader(process.cwd());
62-
const content: string | undefined = fileSystemReader.readSyncAnyOf([
63-
'nest-cli.json',
64-
'.nestcli.json',
65-
'.nest-cli.json',
66-
'nest.json',
67-
]);
63+
const content: string | undefined = fileSystemReader.readSyncAnyOf(
64+
NEST_CLI_CONFIG_FILES,
65+
);
6866

6967
try {
7068
const nestJson = JSON.parse(content || '{}');
@@ -235,6 +233,10 @@ function updateTsConfig(
235233
const packageKey = packagePrefix
236234
? packagePrefix + '/' + packageName
237235
: packageName;
236+
// ESM performs no directory-index resolution, so the alias has to name the
237+
// entry file; CommonJS keeps resolving `./libs/x/src` through index.*.
238+
const aliasTarget =
239+
'./' + distRoot + (isEsmProject(host) ? '/index.ts' : '');
238240

239241
return updateJsonFile(
240242
host,
@@ -250,7 +252,7 @@ function updateTsConfig(
250252
if (!tsconfig.compilerOptions.paths[packageKey]) {
251253
tsconfig.compilerOptions.paths[packageKey] = [];
252254
}
253-
tsconfig.compilerOptions.paths[packageKey].push('./' + distRoot);
255+
tsconfig.compilerOptions.paths[packageKey].push(aliasTarget);
254256

255257
const deepPackagePath = packageKey + '/*';
256258
if (!tsconfig.compilerOptions.paths[deepPackagePath]) {

‎src/lib/sub-app/files/ts/tsconfig.app.json‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
{
22
"extends": "../../tsconfig.json",
33
"compilerOptions": {
4-
"composite": true,
5-
"declaration": true,
6-
"rootDir": "./src",
4+
"declaration": false,
5+
"rootDir": "../..",
76
"outDir": "../../dist/apps/<%= name %>"
87
},
98
"include": ["src/**/*"],

‎src/lib/sub-app/sub-app.factory.ts‎

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import { existsSync, readFileSync } from 'fs';
1919
import { parse, stringify } from 'comment-json';
2020
import { formatFiles } from '../../utils/format-files.rule.js';
2121
import {
22+
findNestCliConfigPath,
2223
inPlaceSortByKeys,
2324
normalizeToKebabOrSnakeCase,
25+
readJsonFile,
2426
} from '../../utils/index.js';
2527
import {
2628
DEFAULT_APPS_PATH,
@@ -35,6 +37,12 @@ import {
3537
import type { SubAppOptions } from './sub-app.schema.js';
3638
import { isEsmProject } from '../../utils/source-root.helpers.js';
3739

40+
/** The builder `nest g app` writes when it converts a workspace to a monorepo. */
41+
const DEFAULT_BUILDER = 'rspack';
42+
43+
/** What the CLI itself assumes when `nest-cli.json` names no builder. */
44+
const CLI_DEFAULT_BUILDER = 'tsc';
45+
3846
type UpdateJsonFn<T> = (obj: T) => T | void;
3947
interface TsConfigPartialType {
4048
compilerOptions?: Record<string, any>;
@@ -59,6 +67,7 @@ export function main(options: SubAppOptions): Rule {
5967
])(tree, context),
6068
addAppsToCliOptions(options.path!, options.name, appName),
6169
addTsConfigReference(options.path!, options.name),
70+
applyStartProdScript(options, appName),
6271
(tree) => {
6372
(options as any).isEsm = isEsmProject(tree);
6473
return tree;
@@ -228,6 +237,34 @@ function updatePackageJson(options: SubAppOptions, defaultAppName: string) {
228237
};
229238
}
230239

240+
type BuilderConfig = { compilerOptions?: { builder?: unknown } };
241+
242+
/**
243+
* Reads the builder in use. `builder` is either a name or a `{ type }` object,
244+
* and the CLI falls back to `tsc` when it is absent - which only happens in a
245+
* workspace that was already a monorepo, since the conversion writes one.
246+
*/
247+
function readBuilder(host: Tree): string {
248+
const path = findNestCliConfigPath(host);
249+
const config = path ? readJsonFile<BuilderConfig>(host, path) : null;
250+
const builder = config?.compilerOptions?.builder;
251+
const name =
252+
typeof builder === 'string'
253+
? builder
254+
: (builder as { type?: unknown } | undefined)?.type;
255+
return typeof name === 'string' && name ? name : CLI_DEFAULT_BUILDER;
256+
}
257+
258+
/**
259+
* Builders that emit one bundle per project rather than mirroring the source
260+
* tree into `outDir`. Everything else - `tsc`, and `swc`, whose
261+
* `stripLeadingPaths` is off whenever `rootDir` sits above the source root -
262+
* writes the nested `dist/<root>/<root>/src/main.js` entry.
263+
*/
264+
function isBundler(builder: string): boolean {
265+
return builder === 'rspack' || builder === 'webpack';
266+
}
267+
231268
function updateNpmScripts(
232269
scripts: Record<string, any>,
233270
options: SubAppOptions,
@@ -237,13 +274,8 @@ function updateNpmScripts(
237274
return;
238275
}
239276
const defaultFormatScriptName = 'format';
240-
const defaultStartScriptName = 'start:prod';
241277
const defaultTestScriptName = 'test:e2e';
242-
if (
243-
!scripts[defaultTestScriptName] &&
244-
!scripts[defaultFormatScriptName] &&
245-
!scripts[defaultStartScriptName]
246-
) {
278+
if (!scripts[defaultTestScriptName] && !scripts[defaultFormatScriptName]) {
247279
return;
248280
}
249281
if (
@@ -269,15 +301,49 @@ function updateNpmScripts(
269301
scripts[defaultFormatScriptName] =
270302
`prettier --write "${defaultSourceRoot}/**/*.ts" "${DEFAULT_LIB_PATH}/**/*.ts"`;
271303
}
272-
if (
273-
scripts[defaultStartScriptName] &&
274-
scripts[defaultStartScriptName].indexOf('dist/main') >= 0
275-
) {
276-
const defaultSourceRoot =
277-
options.rootDir !== undefined ? options.rootDir : DEFAULT_APPS_PATH;
278-
scripts[defaultStartScriptName] =
279-
`node dist/${defaultSourceRoot}/${defaultAppName}/main`;
280-
}
304+
}
305+
306+
/**
307+
* Writes `start:prod` for the builder the workspace ends up using. This has to
308+
* run after the CLI options are final: `nest g app` forces `rspack` when it
309+
* converts a single app into a monorepo, so reading the builder earlier would
310+
* write a script that does not match the final config.
311+
*/
312+
function applyStartProdScript(
313+
options: SubAppOptions,
314+
defaultAppName: string,
315+
): Rule {
316+
return (host: Tree) => {
317+
if (!host.exists('package.json')) {
318+
return host;
319+
}
320+
const builder = readBuilder(host);
321+
return updateJsonFile(
322+
host,
323+
'package.json',
324+
(packageJson: Record<string, Record<string, any>>) => {
325+
const scripts = packageJson.scripts;
326+
const startScript = scripts?.['start:prod'];
327+
if (
328+
typeof startScript !== 'string' ||
329+
!/^node dist\/(.+\/)?main$/.test(startScript)
330+
) {
331+
return;
332+
}
333+
const defaultSourceRoot =
334+
options.rootDir !== undefined ? options.rootDir : DEFAULT_APPS_PATH;
335+
// A bundler ignores `rootDir` and writes `dist/<root>/main.js`, which
336+
// is also where `nest start` falls back to looking. A per-file
337+
// compiler mirrors the source tree under `rootDir` (the workspace
338+
// root) into the project's `outDir`, so the project path appears
339+
// twice: dist/apps/<app>/apps/<app>/src/main.
340+
const projectRoot = `${defaultSourceRoot}/${defaultAppName}`;
341+
scripts['start:prod'] = isBundler(builder)
342+
? `node dist/${projectRoot}/main`
343+
: `node dist/${projectRoot}/${projectRoot}/src/main`;
344+
},
345+
);
346+
};
281347
}
282348

283349
function updateJestOptions(
@@ -411,7 +477,7 @@ function updateMainAppOptions(
411477
if (!optionsFile.compilerOptions) {
412478
optionsFile.compilerOptions = {};
413479
}
414-
optionsFile.compilerOptions.builder = 'rspack';
480+
optionsFile.compilerOptions.builder = DEFAULT_BUILDER;
415481
optionsFile.compilerOptions.tsConfigPath = tsConfigPath;
416482

417483
if (!optionsFile.projects) {

‎src/lib/sub-app/workspace/ts/tsconfig.app.json‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
{
22
"extends": "../../tsconfig.json",
33
"compilerOptions": {
4-
"composite": true,
5-
"declaration": true,
6-
"rootDir": "./src",
4+
"declaration": false,
5+
"rootDir": "../..",
76
"outDir": "../../dist/apps/<%= name %>"
87
},
98
"include": ["src/**/*"],

‎src/lib/upgrade/upgrade.utils.ts‎

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { SchematicContext, Tree } from '@angular-devkit/schematics';
2-
import { parse } from 'jsonc-parser';
32
import type {
43
Node,
54
ObjectLiteralElementLike,
@@ -16,11 +15,18 @@ import {
1615
isStringLiteral,
1716
} from 'typescript';
1817
import { JSONFile } from '../../utils/json-file.util.js';
18+
import {
19+
findNestCliConfigPath,
20+
readJsonFile,
21+
} from '../../utils/nest-cli-config.util.js';
1922
import {
2023
getPackageJsonDependency,
2124
NodeDependencyType,
2225
} from '../../utils/dependencies.utils.js';
2326

27+
// Re-exported so existing `upgrade.utils.js` consumers keep working.
28+
export { findNestCliConfigPath, readJsonFile };
29+
2430
export const MIGRATION_GUIDE_URL = 'https://docs.nestjs.com/migration-guide';
2531

2632
export interface TextEdit {
@@ -114,21 +120,6 @@ export function parseVersion(version: string): number[] {
114120
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [];
115121
}
116122

117-
export function readJsonFile<T = Record<string, any>>(
118-
tree: Tree,
119-
path: string,
120-
): T | null {
121-
const buffer = tree.read(path);
122-
if (!buffer) {
123-
return null;
124-
}
125-
try {
126-
return parse(buffer.toString('utf-8')) as T;
127-
} catch {
128-
return null;
129-
}
130-
}
131-
132123
export function readPackageJson(tree: Tree): Record<string, any> | null {
133124
return readJsonFile(tree, 'package.json');
134125
}
@@ -146,17 +137,6 @@ export interface NestCliConfig {
146137
projects: Record<string, NestCliProject>;
147138
}
148139

149-
const NEST_CLI_CONFIG_FILES = [
150-
'nest-cli.json',
151-
'.nestcli.json',
152-
'.nest-cli.json',
153-
'nest.json',
154-
];
155-
156-
export function findNestCliConfigPath(tree: Tree): string | undefined {
157-
return NEST_CLI_CONFIG_FILES.find((candidate) => tree.exists(candidate));
158-
}
159-
160140
export function readNestCliConfig(tree: Tree): NestCliConfig {
161141
const path = findNestCliConfigPath(tree);
162142
const config = path ? (readJsonFile(tree, path) ?? {}) : {};

‎src/utils/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from './source-root.helpers.js';
1010
export * from './formatting.js';
1111
export * from './jest-module-mapper.js';
1212
export * from './object-sorting.js';
13+
export * from './nest-cli-config.util.js';

‎src/utils/nest-cli-config.util.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Tree } from '@angular-devkit/schematics';
2+
import { parse } from 'jsonc-parser';
3+
4+
/**
5+
* The config file names the CLI looks for, in the order it tries them.
6+
*/
7+
export const NEST_CLI_CONFIG_FILES = [
8+
'nest-cli.json',
9+
'.nestcli.json',
10+
'.nest-cli.json',
11+
'nest.json',
12+
];
13+
14+
/**
15+
* Resolves the Nest CLI config file in use. The CLI loads the first file that
16+
* exists and ignores the remaining candidates, so a config without a `builder`
17+
* is not overridden by a later file.
18+
*/
19+
export function findNestCliConfigPath(tree: Tree): string | undefined {
20+
return NEST_CLI_CONFIG_FILES.find((candidate) => tree.exists(candidate));
21+
}
22+
23+
export function readJsonFile<T = Record<string, any>>(
24+
tree: Tree,
25+
path: string,
26+
): T | null {
27+
const buffer = tree.read(path);
28+
if (!buffer) {
29+
return null;
30+
}
31+
try {
32+
return parse(buffer.toString('utf-8')) as T;
33+
} catch {
34+
return null;
35+
}
36+
}

0 commit comments

Comments
 (0)