Skip to content

Commit 09af707

Browse files
clydinangular-robot[bot]
authored andcommitted
feat(@angular-devkit/build-angular): implement node module license extraction for esbuild builder
When using the experimental esbuild-based browser application builder, the `--extract-licenses` option will now generate an output licenses file when enabled. This option extracts license information for each node module package included in the output files of the built code. This includes JavaScript and CSS output files. The esbuild metafile information generated during the bundling steps is used as the source of information regarding what input files where included and where they are located. A path segment of `node_modules` is used to indicate that a file belongs to a package and its license should be include in the output licenses file. The package name and license field are extracted from the `package.json` file for the package. If a license file (e.g., `LICENSE`) is present in the root of the package, it will also be included in the output licenses file. Custom licenses as defined by the recommended npm custom license text (`SEE LICENSE IN <filename>`) will also be extracted and included in the output license file. For additional information regarding the license field in a `package.json`, see https://docs.npmjs.com/cli/v9/configuring-npm/package-json#license.
1 parent 64b6628 commit 09af707

File tree

4 files changed

+190
-1
lines changed

4 files changed

+190
-1
lines changed

packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts

-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { Schema as BrowserBuilderOptions } from '../browser/schema';
1212
const UNSUPPORTED_OPTIONS: Array<keyof BrowserBuilderOptions> = [
1313
'allowedCommonJsDependencies',
1414
'budgets',
15-
'extractLicenses',
1615
'progress',
1716
'scripts',
1817

packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts

+12
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { getSupportedBrowsers } from '../../utils/supported-browsers';
2222
import { SourceFileCache, createCompilerPlugin } from './compiler-plugin';
2323
import { bundle, logMessages } from './esbuild';
2424
import { logExperimentalWarnings } from './experimental-warnings';
25+
import { extractLicenses } from './license-extractor';
2526
import { NormalizedBrowserOptions, normalizeOptions } from './options';
2627
import { shutdownSassWorkerPool } from './sass-plugin';
2728
import { Schema as BrowserBuilderOptions } from './schema';
@@ -197,11 +198,20 @@ async function execute(
197198
await Promise.all(
198199
outputFiles.map((file) => fs.writeFile(path.join(outputPath, file.path), file.contents)),
199200
);
201+
200202
// Write metafile if stats option is enabled
201203
if (options.stats) {
202204
await fs.writeFile(path.join(outputPath, 'stats.json'), JSON.stringify(metafile, null, 2));
203205
}
204206

207+
// Extract and write licenses for used packages
208+
if (options.extractLicenses) {
209+
await fs.writeFile(
210+
path.join(outputPath, '3rdpartylicenses.txt'),
211+
await extractLicenses(metafile, workspaceRoot),
212+
);
213+
}
214+
205215
// Augment the application with service worker support
206216
// TODO: This should eventually operate on the in-memory files prior to writing the output files
207217
if (serviceWorkerOptions) {
@@ -269,6 +279,7 @@ function createCodeBundleOptions(
269279
conditions: ['es2020', 'es2015', 'module'],
270280
resolveExtensions: ['.ts', '.tsx', '.mjs', '.js'],
271281
metafile: true,
282+
legalComments: options.extractLicenses ? 'none' : 'eof',
272283
logLevel: options.verbose ? 'debug' : 'silent',
273284
minify: optimizationOptions.scripts,
274285
pure: ['forwardRef'],
@@ -397,6 +408,7 @@ function createGlobalStylesBundleOptions(
397408
includePaths: stylePreprocessorOptions?.includePaths,
398409
});
399410
buildOptions.incremental = watch;
411+
buildOptions.legalComments = options.extractLicenses ? 'none' : 'eof';
400412

401413
const namespace = 'angular:styles/global';
402414
buildOptions.entryPoints = {};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import type { Metafile } from 'esbuild';
10+
import { readFile } from 'node:fs/promises';
11+
import path from 'node:path';
12+
13+
/**
14+
* The path segment used to signify that a file is part of a package.
15+
*/
16+
const NODE_MODULE_SEGMENT = 'node_modules';
17+
18+
/**
19+
* String constant for the NPM recommended custom license wording.
20+
*
21+
* See: https://docs.npmjs.com/cli/v9/configuring-npm/package-json#license
22+
*
23+
* Example:
24+
* ```
25+
* {
26+
* "license" : "SEE LICENSE IN <filename>"
27+
* }
28+
* ```
29+
*/
30+
const CUSTOM_LICENSE_TEXT = 'SEE LICENSE IN ';
31+
32+
/**
33+
* A list of commonly named license files found within packages.
34+
*/
35+
const LICENSE_FILES = ['LICENSE', 'LICENSE.txt', 'LICENSE.md'];
36+
37+
/**
38+
* Header text that will be added to the top of the output license extraction file.
39+
*/
40+
const EXTRACTION_FILE_HEADER = '';
41+
42+
/**
43+
* The package entry separator to use within the output license extraction file.
44+
*/
45+
const EXTRACTION_FILE_SEPARATOR = '-'.repeat(80) + '\n';
46+
47+
/**
48+
* Extracts license information for each node module package included in the output
49+
* files of the built code. This includes JavaScript and CSS output files. The esbuild
50+
* metafile generated during the bundling steps is used as the source of information
51+
* regarding what input files where included and where they are located. A path segment
52+
* of `node_modules` is used to indicate that a file belongs to a package and its license
53+
* should be include in the output licenses file.
54+
*
55+
* The package name and license field are extracted from the `package.json` file for the
56+
* package. If a license file (e.g., `LICENSE`) is present in the root of the package, it
57+
* will also be included in the output licenses file.
58+
*
59+
* @param metafile An esbuild metafile object.
60+
* @param rootDirectory The root directory of the workspace.
61+
* @returns A string containing the content of the output licenses file.
62+
*/
63+
export async function extractLicenses(metafile: Metafile, rootDirectory: string) {
64+
let extractedLicenseContent = `${EXTRACTION_FILE_HEADER}\n${EXTRACTION_FILE_SEPARATOR}`;
65+
66+
const seenPaths = new Set<string>();
67+
const seenPackages = new Set<string>();
68+
69+
for (const entry of Object.values(metafile.outputs)) {
70+
for (const [inputPath, { bytesInOutput }] of Object.entries(entry.inputs)) {
71+
// Skip if not included in output
72+
if (bytesInOutput <= 0) {
73+
continue;
74+
}
75+
76+
// Skip already processed paths
77+
if (seenPaths.has(inputPath)) {
78+
continue;
79+
}
80+
seenPaths.add(inputPath);
81+
82+
// Skip non-package paths
83+
if (!inputPath.includes(NODE_MODULE_SEGMENT)) {
84+
continue;
85+
}
86+
87+
// Extract the package name from the path
88+
let baseDirectory = path.join(rootDirectory, inputPath);
89+
let nameOrScope, nameOrFile;
90+
let found = false;
91+
while (baseDirectory !== path.dirname(baseDirectory)) {
92+
const segment = path.basename(baseDirectory);
93+
if (segment === NODE_MODULE_SEGMENT) {
94+
found = true;
95+
break;
96+
}
97+
98+
nameOrFile = nameOrScope;
99+
nameOrScope = segment;
100+
baseDirectory = path.dirname(baseDirectory);
101+
}
102+
103+
// Skip non-package path edge cases that are not caught in the includes check above
104+
if (!found || !nameOrScope) {
105+
continue;
106+
}
107+
108+
const packageName = nameOrScope.startsWith('@')
109+
? `${nameOrScope}/${nameOrFile}`
110+
: nameOrScope;
111+
const packageDirectory = path.join(baseDirectory, packageName);
112+
113+
// Load the package's metadata to find the package's name, version, and license type
114+
const packageJsonPath = path.join(packageDirectory, 'package.json');
115+
let packageJson;
116+
try {
117+
packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as {
118+
name: string;
119+
version: string;
120+
// The object form is deprecated and should only be present in old packages
121+
license?: string | { type: string };
122+
};
123+
} catch {
124+
// Invalid package
125+
continue;
126+
}
127+
128+
// Skip already processed packages
129+
const packageId = `${packageName}@${packageJson.version}`;
130+
if (seenPackages.has(packageId)) {
131+
continue;
132+
}
133+
seenPackages.add(packageId);
134+
135+
// Attempt to find license text inside package
136+
let licenseText = '';
137+
if (
138+
typeof packageJson.license === 'string' &&
139+
packageJson.license.toLowerCase().startsWith(CUSTOM_LICENSE_TEXT)
140+
) {
141+
// Attempt to load the package's custom license
142+
let customLicensePath;
143+
const customLicenseFile = path.normalize(
144+
packageJson.license.slice(CUSTOM_LICENSE_TEXT.length + 1).trim(),
145+
);
146+
if (customLicenseFile.startsWith('..') || path.isAbsolute(customLicenseFile)) {
147+
// Path is attempting to access files outside of the package
148+
// TODO: Issue warning?
149+
} else {
150+
customLicensePath = path.join(packageDirectory, customLicenseFile);
151+
try {
152+
licenseText = await readFile(customLicensePath, 'utf-8');
153+
break;
154+
} catch {}
155+
}
156+
} else {
157+
// Search for a license file within the root of the package
158+
for (const potentialLicense of LICENSE_FILES) {
159+
const packageLicensePath = path.join(packageDirectory, potentialLicense);
160+
try {
161+
licenseText = await readFile(packageLicensePath, 'utf-8');
162+
break;
163+
} catch {}
164+
}
165+
}
166+
167+
// Generate the package's license entry in the output content
168+
extractedLicenseContent += `Package: ${packageJson.name}\n`;
169+
extractedLicenseContent += `License: ${JSON.stringify(packageJson.license, null, 2)}\n`;
170+
extractedLicenseContent += `\n${licenseText}\n`;
171+
extractedLicenseContent += EXTRACTION_FILE_SEPARATOR;
172+
}
173+
}
174+
175+
return extractedLicenseContent;
176+
}

packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts

+2
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export async function normalizeOptions(
136136
buildOptimizer,
137137
crossOrigin,
138138
externalDependencies,
139+
extractLicenses,
139140
inlineStyleLanguage = 'css',
140141
poll,
141142
preserveSymlinks,
@@ -153,6 +154,7 @@ export async function normalizeOptions(
153154
cacheOptions,
154155
crossOrigin,
155156
externalDependencies,
157+
extractLicenses,
156158
inlineStyleLanguage,
157159
stats: !!statsJson,
158160
poll,

0 commit comments

Comments
 (0)