@nx/js
Version:
371 lines (370 loc) • 19.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.syncGenerator = syncGenerator;
const devkit_1 = require("@nx/devkit");
const ignore_1 = require("ignore");
const jsonc_parser_1 = require("jsonc-parser");
const posix_1 = require("node:path/posix");
const sync_generators_1 = require("nx/src/utils/sync-generators");
const ts = require("typescript");
const plugin_1 = require("../../plugins/typescript/plugin");
const COMMON_RUNTIME_TS_CONFIG_FILE_NAMES = [
'tsconfig.app.json',
'tsconfig.lib.json',
'tsconfig.build.json',
'tsconfig.cjs.json',
'tsconfig.esm.json',
'tsconfig.runtime.json',
];
async function syncGenerator(tree) {
// Ensure that the plugin has been wired up in nx.json
const nxJson = (0, devkit_1.readNxJson)(tree);
const tscPluginConfig = nxJson.plugins.find((p) => {
if (typeof p === 'string') {
return p === plugin_1.PLUGIN_NAME;
}
return p.plugin === plugin_1.PLUGIN_NAME;
});
if (!tscPluginConfig) {
throw new sync_generators_1.SyncError(`The "${plugin_1.PLUGIN_NAME}" plugin is not registered`, [
`The "${plugin_1.PLUGIN_NAME}" plugin must be added to the "plugins" array in "nx.json" in order to sync the project graph information to the TypeScript configuration files.`,
]);
}
const tsconfigInfoCaches = {
composite: new Map(),
content: new Map(),
exists: new Map(),
isFile: new Map(),
};
// Root tsconfig containing project references for the whole workspace
const rootTsconfigPath = 'tsconfig.json';
if (!tsconfigExists(tree, tsconfigInfoCaches, rootTsconfigPath)) {
throw new sync_generators_1.SyncError('Missing root "tsconfig.json"', [
`A "tsconfig.json" file must exist in the workspace root in order to sync the project graph information to the TypeScript configuration files.`,
]);
}
const stringifiedRootJsonContents = readRawTsconfigContents(tree, tsconfigInfoCaches, rootTsconfigPath);
const rootTsconfig = (0, devkit_1.parseJson)(stringifiedRootJsonContents);
const projectGraph = await (0, devkit_1.createProjectGraphAsync)();
const projectRoots = new Set();
const tsconfigProjectNodeValues = Object.values(projectGraph.nodes).filter((node) => {
projectRoots.add(node.data.root);
const projectTsconfigPath = (0, devkit_1.joinPathFragments)(node.data.root, 'tsconfig.json');
return tsconfigExists(tree, tsconfigInfoCaches, projectTsconfigPath);
});
const tsSysFromTree = {
...ts.sys,
fileExists(path) {
// Given ts.System.resolve resolve full path for tsconfig within node_modules
// We need to remove the workspace root to ensure we don't have double workspace root within the Tree
const correctPath = path.startsWith(tree.root)
? (0, posix_1.relative)(tree.root, path)
: path;
return tsconfigExists(tree, tsconfigInfoCaches, correctPath);
},
readFile(path) {
// Given ts.System.resolve resolve full path for tsconfig within node_modules
// We need to remove the workspace root to ensure we don't have double workspace root within the Tree
const correctPath = path.startsWith(tree.root)
? (0, posix_1.relative)(tree.root, path)
: path;
return readRawTsconfigContents(tree, tsconfigInfoCaches, correctPath);
},
};
// Track if any changes were made to the tsconfig files. We check the changes
// made by this generator to know if the TS config is out of sync with the
// project graph. Therefore, we don't format the files if there were no changes
// to avoid potential format-only changes that can lead to false positives.
let hasChanges = false;
if (tsconfigProjectNodeValues.length > 0) {
const referencesSet = new Set();
for (const ref of rootTsconfig.references ?? []) {
// reference path is relative to the tsconfig file
const resolvedRefPath = getTsConfigPathFromReferencePath(tree, rootTsconfigPath, ref.path, tsconfigInfoCaches);
if (tsconfigExists(tree, tsconfigInfoCaches, resolvedRefPath)) {
// we only keep the references that still exist
referencesSet.add(normalizeReferencePath(ref.path));
}
else {
hasChanges = true;
}
}
for (const node of tsconfigProjectNodeValues) {
const normalizedPath = normalizeReferencePath(node.data.root);
// Skip the root tsconfig itself
if (node.data.root !== '.' && !referencesSet.has(normalizedPath)) {
referencesSet.add(normalizedPath);
hasChanges = true;
}
}
if (hasChanges) {
const updatedReferences = Array.from(referencesSet)
// Check composite is true in the internal reference before proceeding
.filter((ref) => hasCompositeEnabled(tsSysFromTree, tsconfigInfoCaches, (0, devkit_1.joinPathFragments)(ref, 'tsconfig.json')))
.map((ref) => ({
path: `./${ref}`,
}));
patchTsconfigJsonReferences(tree, tsconfigInfoCaches, rootTsconfigPath, updatedReferences);
}
}
const userOptions = nxJson.sync?.generatorOptions?.['@nx/js:typescript-sync'];
const { runtimeTsConfigFileNames } = {
runtimeTsConfigFileNames: userOptions?.runtimeTsConfigFileNames ??
COMMON_RUNTIME_TS_CONFIG_FILE_NAMES,
};
const collectedDependencies = new Map();
for (const [projectName, data] of Object.entries(projectGraph.dependencies)) {
if (!projectGraph.nodes[projectName] ||
projectGraph.nodes[projectName].data.root === '.') {
continue;
}
// Get the source project nodes for the source and target
const sourceProjectNode = projectGraph.nodes[projectName];
// Find the relevant tsconfig file for the source project
const sourceProjectTsconfigPath = (0, devkit_1.joinPathFragments)(sourceProjectNode.data.root, 'tsconfig.json');
if (!tsconfigExists(tree, tsconfigInfoCaches, sourceProjectTsconfigPath)) {
if (process.env.NX_VERBOSE_LOGGING === 'true') {
devkit_1.logger.warn(`Skipping project "${projectName}" as there is no tsconfig.json file found in the project root "${sourceProjectNode.data.root}".`);
}
continue;
}
// Collect the dependencies of the source project
const dependencies = collectProjectDependencies(tree, projectName, projectGraph, collectedDependencies);
for (const runtimeTsConfigFileName of runtimeTsConfigFileNames) {
const runtimeTsConfigPath = (0, devkit_1.joinPathFragments)(sourceProjectNode.data.root, runtimeTsConfigFileName);
if (!tsconfigExists(tree, tsconfigInfoCaches, runtimeTsConfigPath)) {
continue;
}
// Update project references for the runtime tsconfig
hasChanges =
updateTsConfigReferences(tree, tsSysFromTree, tsconfigInfoCaches, runtimeTsConfigPath, dependencies, sourceProjectNode.data.root, projectRoots, runtimeTsConfigFileName, runtimeTsConfigFileNames) || hasChanges;
}
// Update project references for the tsconfig.json file
hasChanges =
updateTsConfigReferences(tree, tsSysFromTree, tsconfigInfoCaches, sourceProjectTsconfigPath, dependencies, sourceProjectNode.data.root, projectRoots) || hasChanges;
}
if (hasChanges) {
await (0, devkit_1.formatFiles)(tree);
return {
outOfSyncMessage: 'Some TypeScript configuration files are missing project references to the projects they depend on or contain outdated project references.',
};
}
}
exports.default = syncGenerator;
/**
* Within the context of a sync generator, performance is a key concern,
* so avoid FS interactions whenever possible.
*/
function readRawTsconfigContents(tree, tsconfigInfoCaches, tsconfigPath) {
if (!tsconfigInfoCaches.content.has(tsconfigPath)) {
tsconfigInfoCaches.content.set(tsconfigPath, tree.read(tsconfigPath, 'utf-8'));
}
return tsconfigInfoCaches.content.get(tsconfigPath);
}
/**
* Within the context of a sync generator, performance is a key concern,
* so avoid FS interactions whenever possible.
*/
function tsconfigExists(tree, tsconfigInfoCaches, tsconfigPath) {
if (!tsconfigInfoCaches.exists.has(tsconfigPath)) {
tsconfigInfoCaches.exists.set(tsconfigPath, tree.exists(tsconfigPath));
}
return tsconfigInfoCaches.exists.get(tsconfigPath);
}
function updateTsConfigReferences(tree, tsSysFromTree, tsconfigInfoCaches, tsConfigPath, dependencies, projectRoot, projectRoots, runtimeTsConfigFileName, possibleRuntimeTsConfigFileNames) {
const stringifiedJsonContents = readRawTsconfigContents(tree, tsconfigInfoCaches, tsConfigPath);
const tsConfig = (0, devkit_1.parseJson)(stringifiedJsonContents);
const ignoredReferences = new Set(tsConfig.nx?.sync?.ignoredReferences ?? []);
// We have at least one dependency so we can safely set it to an empty array if not already set
const references = [];
const originalReferencesSet = new Set();
const newReferencesSet = new Set();
for (const ref of tsConfig.references ?? []) {
const normalizedPath = normalizeReferencePath(ref.path);
originalReferencesSet.add(normalizedPath);
if (ignoredReferences.has(ref.path)) {
// we keep the user-defined ignored references
references.push(ref);
newReferencesSet.add(normalizedPath);
continue;
}
// reference path is relative to the tsconfig file
const resolvedRefPath = getTsConfigPathFromReferencePath(tree, tsConfigPath, ref.path, tsconfigInfoCaches);
if (isProjectReferenceWithinNxProject(tree, tsconfigInfoCaches, resolvedRefPath, projectRoot, projectRoots) ||
isProjectReferenceIgnored(tree, resolvedRefPath)) {
// we keep all references within the current Nx project or that are ignored
references.push(ref);
newReferencesSet.add(normalizedPath);
}
}
let hasChanges = false;
for (const dep of dependencies) {
// Ensure the project reference for the target is set if we can find the
// relevant tsconfig file
let referencePath;
if (runtimeTsConfigFileName) {
const runtimeTsConfigPath = (0, devkit_1.joinPathFragments)(dep.data.root, runtimeTsConfigFileName);
if (tsconfigExists(tree, tsconfigInfoCaches, runtimeTsConfigPath)) {
// Check composite is true in the dependency runtime tsconfig file before proceeding
if (!hasCompositeEnabled(tsSysFromTree, tsconfigInfoCaches, runtimeTsConfigPath)) {
continue;
}
referencePath = runtimeTsConfigPath;
}
else {
// Check for other possible runtime tsconfig file names
// TODO(leo): should we check if there are more than one runtime tsconfig files and throw an error?
for (const possibleRuntimeTsConfigFileName of possibleRuntimeTsConfigFileNames ??
[]) {
const possibleRuntimeTsConfigPath = (0, devkit_1.joinPathFragments)(dep.data.root, possibleRuntimeTsConfigFileName);
if (tsconfigExists(tree, tsconfigInfoCaches, possibleRuntimeTsConfigPath)) {
// Check composite is true in the dependency runtime tsconfig file before proceeding
if (!hasCompositeEnabled(tsSysFromTree, tsconfigInfoCaches, possibleRuntimeTsConfigPath)) {
continue;
}
referencePath = possibleRuntimeTsConfigPath;
break;
}
}
}
}
else {
// Check composite is true in the dependency tsconfig.json file before proceeding
if (!hasCompositeEnabled(tsSysFromTree, tsconfigInfoCaches, (0, devkit_1.joinPathFragments)(dep.data.root, 'tsconfig.json'))) {
continue;
}
}
if (!referencePath) {
if (tsconfigExists(tree, tsconfigInfoCaches, (0, devkit_1.joinPathFragments)(dep.data.root, 'tsconfig.json'))) {
referencePath = dep.data.root;
}
else {
continue;
}
}
const relativePathToTargetRoot = (0, posix_1.relative)(projectRoot, referencePath);
if (!newReferencesSet.has(relativePathToTargetRoot)) {
newReferencesSet.add(relativePathToTargetRoot);
// Make sure we unshift rather than push so that dependencies are built in the right order by TypeScript when it is run directly from the root of the workspace
references.unshift({ path: relativePathToTargetRoot });
}
if (!originalReferencesSet.has(relativePathToTargetRoot)) {
hasChanges = true;
}
}
hasChanges ||= newReferencesSet.size !== originalReferencesSet.size;
if (hasChanges) {
patchTsconfigJsonReferences(tree, tsconfigInfoCaches, tsConfigPath, references);
}
return hasChanges;
}
// TODO(leo): follow up with the TypeScript team to confirm if we really need
// to reference transitive dependencies.
// Collect the dependencies of a project recursively sorted from root to leaf
function collectProjectDependencies(tree, projectName, projectGraph, collectedDependencies) {
if (collectedDependencies.has(projectName)) {
// We've already collected the dependencies for this project
return collectedDependencies.get(projectName);
}
collectedDependencies.set(projectName, []);
for (const dep of projectGraph.dependencies[projectName]) {
const targetProjectNode = projectGraph.nodes[dep.target];
if (!targetProjectNode || dep.type === 'implicit') {
// It's an npm or an implicit dependency
continue;
}
// Add the target project node to the list of dependencies for the current project
if (!collectedDependencies
.get(projectName)
.some((d) => d.name === targetProjectNode.name)) {
collectedDependencies.get(projectName).push(targetProjectNode);
}
if (process.env.NX_ENABLE_TS_SYNC_TRANSITIVE_DEPENDENCIES !== 'true') {
continue;
}
// Recursively get the dependencies of the target project
const transitiveDependencies = collectProjectDependencies(tree, dep.target, projectGraph, collectedDependencies);
for (const transitiveDep of transitiveDependencies) {
if (!collectedDependencies
.get(projectName)
.some((d) => d.name === transitiveDep.name)) {
collectedDependencies.get(projectName).push(transitiveDep);
}
}
}
return collectedDependencies.get(projectName);
}
// Normalize the paths to strip leading `./` and trailing `/tsconfig.json`
function normalizeReferencePath(path) {
return (0, posix_1.normalize)(path)
.replace(/\/tsconfig.json$/, '')
.replace(/^\.\//, '');
}
function isProjectReferenceWithinNxProject(tree, tsconfigInfoCaches, refTsConfigPath, projectRoot, projectRoots) {
let currentPath = getTsConfigDirName(tree, tsconfigInfoCaches, refTsConfigPath);
if ((0, posix_1.relative)(projectRoot, currentPath).startsWith('..')) {
// it's outside of the project root, so it's an external project reference
return false;
}
while (currentPath !== projectRoot) {
if (projectRoots.has(currentPath)) {
// it's inside a nested project root, so it's and external project reference
return false;
}
currentPath = (0, posix_1.dirname)(currentPath);
}
// it's inside the project root, so it's an internal project reference
return true;
}
function isProjectReferenceIgnored(tree, refTsConfigPath) {
const ig = (0, ignore_1.default)();
if (tree.exists('.gitignore')) {
ig.add('.git');
ig.add(tree.read('.gitignore', 'utf-8'));
}
if (tree.exists('.nxignore')) {
ig.add(tree.read('.nxignore', 'utf-8'));
}
return ig.ignores(refTsConfigPath);
}
function getTsConfigDirName(tree, tsconfigInfoCaches, tsConfigPath) {
return tsconfigIsFile(tree, tsconfigInfoCaches, tsConfigPath)
? (0, posix_1.dirname)(tsConfigPath)
: (0, posix_1.normalize)(tsConfigPath);
}
function getTsConfigPathFromReferencePath(tree, ownerTsConfigPath, referencePath, tsconfigInfoCaches) {
const resolvedRefPath = (0, devkit_1.joinPathFragments)((0, posix_1.dirname)(ownerTsConfigPath), referencePath);
return tsconfigIsFile(tree, tsconfigInfoCaches, resolvedRefPath)
? resolvedRefPath
: (0, devkit_1.joinPathFragments)(resolvedRefPath, 'tsconfig.json');
}
/**
* Minimally patch just the "references" property within the tsconfig file at a given path.
* This allows comments in other sections of the file to remain intact when syncing is run.
*/
function patchTsconfigJsonReferences(tree, tsconfigInfoCaches, tsconfigPath, updatedReferences) {
const stringifiedJsonContents = readRawTsconfigContents(tree, tsconfigInfoCaches, tsconfigPath);
const edits = (0, jsonc_parser_1.modify)(stringifiedJsonContents, ['references'], updatedReferences, { formattingOptions: { keepLines: true, insertSpaces: true, tabSize: 2 } });
const updatedJsonContents = (0, jsonc_parser_1.applyEdits)(stringifiedJsonContents, edits);
// The final contents will be formatted by formatFiles() later
tree.write(tsconfigPath, updatedJsonContents);
}
function hasCompositeEnabled(tsSysFromTree, tsconfigInfoCaches, tsconfigPath) {
if (!tsconfigInfoCaches.composite.has(tsconfigPath)) {
const parsed = ts.parseJsonConfigFileContent(ts.readConfigFile(tsconfigPath, tsSysFromTree.readFile).config, tsSysFromTree, (0, posix_1.dirname)(tsconfigPath));
tsconfigInfoCaches.composite.set(tsconfigPath, parsed.options.composite === true);
}
return tsconfigInfoCaches.composite.get(tsconfigPath);
}
function tsconfigIsFile(tree, tsconfigInfoCaches, tsconfigPath) {
if (tsconfigInfoCaches.isFile.has(tsconfigPath)) {
return tsconfigInfoCaches.isFile.get(tsconfigPath);
}
if (tsconfigInfoCaches.content.has(tsconfigPath)) {
// if it has content, it's a file
tsconfigInfoCaches.isFile.set(tsconfigPath, true);
return true;
}
tsconfigInfoCaches.isFile.set(tsconfigPath, tree.isFile(tsconfigPath));
return tsconfigInfoCaches.isFile.get(tsconfigPath);
}