-
Notifications
You must be signed in to change notification settings - Fork 406
[Package] Exclude unused dependencies when building package.json files
#3232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from all commits
dba3671
2e7b092
f01b374
47d94f6
e629587
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,11 @@ | ||
| import * as fs from 'fs'; | ||
| import { createPackageJson } from '@nx/js'; | ||
| import type { ExecutorContext, ProjectGraphDependency } from '@nx/devkit'; | ||
| import type { | ||
| ExecutorContext, | ||
| FileData, | ||
| ProjectFileMap, | ||
| ProjectGraphDependency, | ||
| } from '@nx/devkit'; | ||
| import { | ||
| serializeJson, | ||
| logger, | ||
|
|
@@ -12,6 +17,8 @@ import { | |
| HelperDependency, | ||
| readTsConfig, | ||
| } from '@nx/js'; | ||
| import { readFileMapCache } from 'nx/src/project-graph/nx-deps-cache'; | ||
| import { fileDataDepTarget } from 'nx/src/config/project-graph'; | ||
| import type { PackageJsonExecutorSchema } from './schema'; | ||
|
|
||
| interface ExecutorEvent { | ||
|
|
@@ -52,7 +59,12 @@ export default async function* packageJsonExecutor( | |
| }); | ||
| } | ||
|
|
||
| const monorepoDependencies = getMonorepoDependencies(context); | ||
| const sourceFileMap = getSourceOnlyFileMap(); | ||
| const sourceDeps = getSourceDependencyTargets( | ||
| sourceFileMap, | ||
| context.projectName | ||
| ); | ||
| const monorepoDependencies = getMonorepoDependencies(context, sourceDeps); | ||
|
|
||
| // Read optional dependencies from the original package.json | ||
| let originalOptionalDependencies: Record<string, string> | undefined; | ||
|
|
@@ -75,7 +87,9 @@ export default async function* packageJsonExecutor( | |
| context, | ||
| helperDependencies, | ||
| monorepoDependencies, | ||
| originalOptionalDependencies | ||
| originalOptionalDependencies, | ||
| sourceFileMap, | ||
| sourceDeps | ||
| ); | ||
| if (built === false) { | ||
| return { | ||
|
|
@@ -109,7 +123,9 @@ async function buildPackageJson( | |
| context: ExecutorContext, | ||
| helperDependencies: ProjectGraphDependency[], | ||
| monorepoDependencies: MonorepoDependency[], | ||
| originalOptionalDependencies?: Record<string, string> | ||
| originalOptionalDependencies?: Record<string, string>, | ||
| sourceFileMap?: ProjectFileMap, | ||
| sourceDeps?: Set<string> | ||
| ) { | ||
| const packageJson = createPackageJson( | ||
| context.projectName, | ||
|
|
@@ -119,7 +135,8 @@ async function buildPackageJson( | |
| root: context.root, | ||
| isProduction: true, | ||
| helperDependencies: helperDependencies.map((dep) => dep.target), | ||
| } as any | ||
| } as any, | ||
| sourceFileMap | ||
| ); | ||
|
|
||
| let main = packageJson.main ?? event.outfile; | ||
|
|
@@ -136,6 +153,17 @@ async function buildPackageJson( | |
| packageJson.dependencies = {}; | ||
| } | ||
|
|
||
| // Remove external dependencies that are not directly imported by source | ||
| // files. createPackageJson flattens transitive dependencies, but published | ||
| // libraries should only declare their direct dependencies. | ||
| if (sourceDeps) { | ||
| for (const name of Object.keys(packageJson.dependencies)) { | ||
| if (!sourceDeps.has(`npm:${name}`)) { | ||
| delete packageJson.dependencies[name]; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| for (const dep of monorepoDependencies) { | ||
| packageJson.dependencies[dep.name] = dep.version; | ||
| } | ||
|
|
@@ -191,8 +219,41 @@ interface MonorepoDependency { | |
| version: string; | ||
| } | ||
|
|
||
| function isSourceFile(filePath: string): boolean { | ||
| return !/\/test\//.test(filePath); | ||
mho22 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| function getSourceOnlyFileMap(): ProjectFileMap { | ||
| const cache = readFileMapCache(); | ||
| const fullFileMap = cache?.fileMap?.projectFileMap || {}; | ||
| const filtered: ProjectFileMap = {}; | ||
| for (const [project, files] of Object.entries(fullFileMap)) { | ||
| filtered[project] = (files as FileData[]).filter((f) => | ||
| isSourceFile(f.file) | ||
| ); | ||
| } | ||
| return filtered; | ||
| } | ||
|
Comment on lines
+226
to
+236
|
||
|
|
||
| function getSourceDependencyTargets( | ||
| sourceFileMap: ProjectFileMap, | ||
| projectName?: string | ||
| ): Set<string> { | ||
| const targets = new Set<string>(); | ||
| if (projectName) { | ||
| const projectFiles = sourceFileMap[projectName] || []; | ||
| for (const fileData of projectFiles) { | ||
| for (const dep of fileData.deps || []) { | ||
| targets.add(fileDataDepTarget(dep)); | ||
| } | ||
| } | ||
| } | ||
| return targets; | ||
| } | ||
|
|
||
| function getMonorepoDependencies( | ||
| context: ExecutorContext | ||
| context: ExecutorContext, | ||
| sourceDeps: Set<string> | ||
| ): MonorepoDependency[] { | ||
| const monorepoDeps: MonorepoDependency[] = []; | ||
| for (const repoDep of context.projectGraph.dependencies[ | ||
|
|
@@ -207,6 +268,9 @@ function getMonorepoDependencies( | |
| if (!(repoDep.target in context.projectGraph.nodes)) { | ||
| continue; | ||
| } | ||
| if (!sourceDeps.has(repoDep.target)) { | ||
| continue; | ||
| } | ||
| const targetSourceRoot = | ||
| context.projectGraph.nodes[repoDep.target].data.root; | ||
| const packageJsonPath = `${targetSourceRoot}/package.json`; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import './blob'; | ||
| import '../lib/blob'; | ||
|
|
||
| describe('File class', () => { | ||
| it('Should exist', () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,117 +1 @@ | ||
| /** | ||
| * Scopes are unique strings, like `my-site`, used to uniquely brand | ||
| * the outgoing HTTP traffic from each browser tab. This helps the | ||
| * main thread distinguish between the relevant and irrelevant | ||
| * messages received from the Service Worker. | ||
| * | ||
| * Scopes are included in the `PHPRequestHandler.absoluteUrl` as follows: | ||
| * | ||
| * An **unscoped** URL: http://localhost:8778/wp-login.php | ||
| * A **scoped** URL: http://localhost:8778/scope:my-site/wp-login.php | ||
| * | ||
| * For more information, see the README section on scopes. | ||
| */ | ||
|
|
||
| /** | ||
| * Checks if the given URL contains scope information. | ||
| * | ||
| * @example | ||
| * ```js | ||
| * isURLScoped(new URL('http://localhost/scope:my-site/index.php')); | ||
| * // true | ||
| * | ||
| * isURLScoped(new URL('http://localhost/index.php')); | ||
| * // false | ||
| * ``` | ||
| * | ||
| * @param url The URL to check. | ||
| * @returns `true` if the URL contains scope information, `false` otherwise. | ||
| */ | ||
| export function isURLScoped(url: URL): boolean { | ||
| return url.pathname.startsWith(`/scope:`); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the scope stored in the given URL. | ||
| * | ||
| * @example | ||
| * ```js | ||
| * getScopeFromURL(new URL('http://localhost/scope:my-site/index.php')); | ||
| * // '96253' | ||
| * | ||
| * getScopeFromURL(new URL('http://localhost/index.php')); | ||
| * // null | ||
| * ``` | ||
| * | ||
| * @param url The URL. | ||
| * @returns The scope if the URL contains a scope, `null` otherwise. | ||
| */ | ||
| export function getURLScope(url: URL): string | null { | ||
| if (isURLScoped(url)) { | ||
| return url.pathname.split('/')[1].split(':')[1]; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a new URL with the requested scope information. | ||
| * | ||
| * @example | ||
| * ```js | ||
| * setURLScope(new URL('http://localhost/index.php'), 'my-site'); | ||
| * // URL('http://localhost/scope:my-site/index.php') | ||
| * | ||
| * setURLScope(new URL('http://localhost/scope:my-site/index.php'), 'my-site'); | ||
| * // URL('http://localhost/scope:my-site/index.php') | ||
| * | ||
| * setURLScope(new URL('http://localhost/index.php'), null); | ||
| * // URL('http://localhost/index.php') | ||
| * ``` | ||
| * | ||
| * @param url The URL to scope. | ||
| * @param scope The scope value. | ||
| * @returns A new URL with the scope information in it. | ||
| */ | ||
| export function setURLScope(url: URL | string, scope: string | null): URL { | ||
| let newUrl = new URL(url); | ||
|
|
||
| if (isURLScoped(newUrl)) { | ||
| if (scope) { | ||
| const parts = newUrl.pathname.split('/'); | ||
| parts[1] = `scope:${scope}`; | ||
| newUrl.pathname = parts.join('/'); | ||
| } else { | ||
| newUrl = removeURLScope(newUrl); | ||
| } | ||
| } else if (scope) { | ||
| const suffix = newUrl.pathname === '/' ? '' : newUrl.pathname; | ||
| newUrl.pathname = `/scope:${scope}${suffix}`; | ||
| } | ||
|
|
||
| return newUrl; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a new URL without any scope information. | ||
| * | ||
| * @example | ||
| * ```js | ||
| * removeURLScope(new URL('http://localhost/scope:my-site/index.php')); | ||
| * // URL('http://localhost/index.php') | ||
| * | ||
| * removeURLScope(new URL('http://localhost/index.php')); | ||
| * // URL('http://localhost/index.php') | ||
| * ``` | ||
| * | ||
| * @param url The URL to remove scope information from. | ||
| * @returns A new URL without the scope information. | ||
| */ | ||
| export function removeURLScope(url: URL): URL { | ||
| if (!isURLScoped(url)) { | ||
| return url; | ||
| } | ||
| const newUrl = new URL(url); | ||
| const parts = newUrl.pathname.split('/'); | ||
| newUrl.pathname = '/' + parts.slice(2).join('/'); | ||
| return newUrl; | ||
| } | ||
| export * from './lib/scope'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| /** | ||
| * Scopes are unique strings, like `my-site`, used to uniquely brand | ||
| * the outgoing HTTP traffic from each browser tab. This helps the | ||
| * main thread distinguish between the relevant and irrelevant | ||
| * messages received from the Service Worker. | ||
| * | ||
| * Scopes are included in the `PHPRequestHandler.absoluteUrl` as follows: | ||
| * | ||
| * An **unscoped** URL: http://localhost:8778/wp-login.php | ||
| * A **scoped** URL: http://localhost:8778/scope:my-site/wp-login.php | ||
| * | ||
| * For more information, see the README section on scopes. | ||
| */ | ||
|
|
||
| /** | ||
| * Checks if the given URL contains scope information. | ||
| * | ||
| * @example | ||
| * ```js | ||
| * isURLScoped(new URL('http://localhost/scope:my-site/index.php')); | ||
| * // true | ||
| * | ||
| * isURLScoped(new URL('http://localhost/index.php')); | ||
| * // false | ||
| * ``` | ||
| * | ||
| * @param url The URL to check. | ||
| * @returns `true` if the URL contains scope information, `false` otherwise. | ||
| */ | ||
| export function isURLScoped(url: URL): boolean { | ||
| return url.pathname.startsWith(`/scope:`); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the scope stored in the given URL. | ||
| * | ||
| * @example | ||
| * ```js | ||
| * getScopeFromURL(new URL('http://localhost/scope:my-site/index.php')); | ||
| * // '96253' | ||
| * | ||
| * getScopeFromURL(new URL('http://localhost/index.php')); | ||
mho22 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| * // null | ||
| * ``` | ||
| * | ||
| * @param url The URL. | ||
| * @returns The scope if the URL contains a scope, `null` otherwise. | ||
| */ | ||
| export function getURLScope(url: URL): string | null { | ||
| if (isURLScoped(url)) { | ||
| return url.pathname.split('/')[1].split(':')[1]; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a new URL with the requested scope information. | ||
| * | ||
| * @example | ||
| * ```js | ||
| * setURLScope(new URL('http://localhost/index.php'), 'my-site'); | ||
| * // URL('http://localhost/scope:my-site/index.php') | ||
| * | ||
| * setURLScope(new URL('http://localhost/scope:my-site/index.php'), 'my-site'); | ||
| * // URL('http://localhost/scope:my-site/index.php') | ||
| * | ||
| * setURLScope(new URL('http://localhost/index.php'), null); | ||
| * // URL('http://localhost/index.php') | ||
| * ``` | ||
| * | ||
| * @param url The URL to scope. | ||
| * @param scope The scope value. | ||
| * @returns A new URL with the scope information in it. | ||
| */ | ||
| export function setURLScope(url: URL | string, scope: string | null): URL { | ||
| let newUrl = new URL(url); | ||
|
|
||
| if (isURLScoped(newUrl)) { | ||
| if (scope) { | ||
| const parts = newUrl.pathname.split('/'); | ||
| parts[1] = `scope:${scope}`; | ||
| newUrl.pathname = parts.join('/'); | ||
| } else { | ||
| newUrl = removeURLScope(newUrl); | ||
| } | ||
| } else if (scope) { | ||
| const suffix = newUrl.pathname === '/' ? '' : newUrl.pathname; | ||
| newUrl.pathname = `/scope:${scope}${suffix}`; | ||
| } | ||
|
|
||
| return newUrl; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a new URL without any scope information. | ||
| * | ||
| * @example | ||
| * ```js | ||
| * removeURLScope(new URL('http://localhost/scope:my-site/index.php')); | ||
| * // URL('http://localhost/index.php') | ||
| * | ||
| * removeURLScope(new URL('http://localhost/index.php')); | ||
| * // URL('http://localhost/index.php') | ||
| * ``` | ||
| * | ||
| * @param url The URL to remove scope information from. | ||
| * @returns A new URL without the scope information. | ||
| */ | ||
| export function removeURLScope(url: URL): URL { | ||
| if (!isURLScoped(url)) { | ||
| return url; | ||
| } | ||
| const newUrl = new URL(url); | ||
| const parts = newUrl.pathname.split('/'); | ||
| newUrl.pathname = '/' + parts.slice(2).join('/'); | ||
| return newUrl; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These imports reach into Nx internal source paths (
nx/src/...), which are not part of the public API surface and can break on Nx upgrades or even patch releases. If possible, prefer a public@nx/devkitAPI (or encapsulate this behind a small compatibility layer with a well-defined fallback) so the executor remains stable across Nx versions.