From 584b51907c3b3f60db5478994fff3f800b70c3f2 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 8 Nov 2022 10:06:12 -0500 Subject: [PATCH] feat(@angular-devkit/build-angular): support scripts option with esbuild builder When using the esbuild-based browser application builder, the `scripts` option will now provide equivalent functionality to the current default Webpack-based builder. The option provides full node resolution capabilities which allows both workspace relative paths and package paths with support for the `script` exports condition. --- .../browser-esbuild/experimental-warnings.ts | 1 - .../browser-esbuild/global-scripts.ts | 114 +++++ .../src/builders/browser-esbuild/index.ts | 44 +- .../src/builders/browser-esbuild/options.ts | 10 +- .../src/builders/browser-esbuild/schema.json | 3 +- .../tests/options/scripts_spec.ts | 438 ++++++++++++++++++ 6 files changed, 598 insertions(+), 12 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/browser-esbuild/global-scripts.ts create mode 100644 packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/options/scripts_spec.ts diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts index 1c4d31eabe20..fb9c71c5dc56 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts @@ -12,7 +12,6 @@ import { Schema as BrowserBuilderOptions } from '../browser/schema'; const UNSUPPORTED_OPTIONS: Array = [ 'budgets', 'progress', - 'scripts', // * i18n support 'localize', diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/global-scripts.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/global-scripts.ts new file mode 100644 index 000000000000..151fdd310fc7 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/global-scripts.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import type { BuildOptions } from 'esbuild'; +import MagicString, { Bundle } from 'magic-string'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { NormalizedBrowserOptions } from './options'; + +/** + * Create an esbuild 'build' options object for all global scripts defined in the user provied + * build options. + * @param options The builder's user-provider normalized options. + * @returns An esbuild BuildOptions object. + */ +export function createGlobalScriptsBundleOptions(options: NormalizedBrowserOptions): BuildOptions { + const { + globalScripts, + optimizationOptions, + outputNames, + preserveSymlinks, + sourcemapOptions, + workspaceRoot, + } = options; + + const namespace = 'angular:script/global'; + const entryPoints: Record = {}; + for (const { name } of globalScripts) { + entryPoints[name] = `${namespace}:${name}`; + } + + return { + absWorkingDir: workspaceRoot, + bundle: false, + splitting: false, + entryPoints, + entryNames: outputNames.bundles, + assetNames: outputNames.media, + mainFields: ['script', 'browser', 'main'], + conditions: ['script'], + resolveExtensions: ['.mjs', '.js'], + logLevel: options.verbose ? 'debug' : 'silent', + metafile: true, + minify: optimizationOptions.scripts, + outdir: workspaceRoot, + sourcemap: sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true), + write: false, + platform: 'neutral', + preserveSymlinks, + plugins: [ + { + name: 'angular-global-scripts', + setup(build) { + build.onResolve({ filter: /^angular:script\/global:/ }, (args) => { + if (args.kind !== 'entry-point') { + return null; + } + + return { + // Add the `js` extension here so that esbuild generates an output file with the extension + path: args.path.slice(namespace.length + 1) + '.js', + namespace, + }; + }); + // All references within a global script should be considered external. This maintains the runtime + // behavior of the script as if it were added directly to a script element for referenced imports. + build.onResolve({ filter: /./, namespace }, ({ path }) => { + return { + path, + external: true, + }; + }); + build.onLoad({ filter: /./, namespace }, async (args) => { + const files = globalScripts.find(({ name }) => name === args.path.slice(0, -3))?.files; + assert(files, `Invalid operation: global scripts name not found [${args.path}]`); + + // Global scripts are concatenated using magic-string instead of bundled via esbuild. + const bundleContent = new Bundle(); + for (const filename of files) { + const resolveResult = await build.resolve(filename, { + kind: 'entry-point', + resolveDir: workspaceRoot, + }); + + if (resolveResult.errors.length) { + // Remove resolution failure notes about marking as external since it doesn't apply + // to global scripts. + resolveResult.errors.forEach((error) => (error.notes = [])); + + return { + errors: resolveResult.errors, + warnings: resolveResult.warnings, + }; + } + + const fileContent = await readFile(resolveResult.path, 'utf-8'); + bundleContent.addSource(new MagicString(fileContent, { filename })); + } + + return { + contents: bundleContent.toString(), + loader: 'js', + }; + }); + }, + }, + ], + }; +} diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts index a699f08f2063..b05ffa6bc72c 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts @@ -25,6 +25,7 @@ import { checkCommonJSModules } from './commonjs-checker'; import { SourceFileCache, createCompilerPlugin } from './compiler-plugin'; import { BundlerContext, logMessages } from './esbuild'; import { logExperimentalWarnings } from './experimental-warnings'; +import { createGlobalScriptsBundleOptions } from './global-scripts'; import { extractLicenses } from './license-extractor'; import { NormalizedBrowserOptions, normalizeOptions } from './options'; import { shutdownSassWorkerPool } from './sass-plugin'; @@ -124,17 +125,28 @@ async function execute( createGlobalStylesBundleOptions(options, target, browsers), ); - const [codeResults, styleResults] = await Promise.all([ + const globalScriptsBundleContext = new BundlerContext( + workspaceRoot, + !!options.watch, + createGlobalScriptsBundleOptions(options), + ); + + const [codeResults, styleResults, scriptResults] = await Promise.all([ // Execute esbuild to bundle the application code codeBundleContext.bundle(), // Execute esbuild to bundle the global stylesheets globalStylesBundleContext.bundle(), + globalScriptsBundleContext.bundle(), ]); // Log all warnings and errors generated during bundling await logMessages(context, { - errors: [...(codeResults.errors || []), ...(styleResults.errors || [])], - warnings: [...codeResults.warnings, ...styleResults.warnings], + errors: [ + ...(codeResults.errors || []), + ...(styleResults.errors || []), + ...(scriptResults.errors || []), + ], + warnings: [...codeResults.warnings, ...styleResults.warnings, ...scriptResults.warnings], }); const executionResult = new ExecutionResult( @@ -144,7 +156,7 @@ async function execute( ); // Return if the bundling has errors - if (codeResults.errors || styleResults.errors) { + if (codeResults.errors || styleResults.errors || scriptResults.errors) { return executionResult; } @@ -154,13 +166,29 @@ async function execute( ); // Combine the bundling output files - const initialFiles: FileInfo[] = [...codeResults.initialFiles, ...styleResults.initialFiles]; - executionResult.outputFiles.push(...codeResults.outputFiles, ...styleResults.outputFiles); + const initialFiles: FileInfo[] = [ + ...codeResults.initialFiles, + ...styleResults.initialFiles, + ...scriptResults.initialFiles, + ]; + executionResult.outputFiles.push( + ...codeResults.outputFiles, + ...styleResults.outputFiles, + ...scriptResults.outputFiles, + ); // Combine metafiles used for the stats option as well as bundle budgets and console output const metafile = { - inputs: { ...codeResults.metafile?.inputs, ...styleResults.metafile?.inputs }, - outputs: { ...codeResults.metafile?.outputs, ...styleResults.metafile?.outputs }, + inputs: { + ...codeResults.metafile?.inputs, + ...styleResults.metafile?.inputs, + ...scriptResults.metafile?.inputs, + }, + outputs: { + ...codeResults.metafile?.outputs, + ...styleResults.metafile?.outputs, + ...scriptResults.metafile?.outputs, + }, }; // Check metafile for CommonJS module usage if optimizing scripts diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts index acc9fe176546..c29d31e4d4d7 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts @@ -14,7 +14,7 @@ import { normalizeAssetPatterns, normalizeOptimization, normalizeSourceMaps } fr import { normalizeCacheOptions } from '../../utils/normalize-cache'; import { generateEntryPoints } from '../../utils/package-chunk-sort'; import { getIndexInputFile, getIndexOutputFile } from '../../utils/webpack-browser-config'; -import { normalizeGlobalStyles } from '../../webpack/utils/helpers'; +import { globalScriptsByBundleName, normalizeGlobalStyles } from '../../webpack/utils/helpers'; import { Schema as BrowserBuilderOptions, OutputHashing } from './schema'; export type NormalizedBrowserOptions = Awaited>; @@ -88,6 +88,13 @@ export async function normalizeOptions( } } + const globalScripts: { name: string; files: string[]; initial: boolean }[] = []; + if (options.scripts?.length) { + for (const { bundleName, paths, inject } of globalScriptsByBundleName(options.scripts)) { + globalScripts.push({ name: bundleName, files: paths, initial: inject }); + } + } + let tailwindConfiguration: { file: string; package: string } | undefined; const tailwindConfigurationPath = findTailwindConfigurationFile(workspaceRoot, projectRoot); if (tailwindConfigurationPath) { @@ -186,6 +193,7 @@ export async function normalizeOptions( outputNames, fileReplacements, globalStyles, + globalScripts, serviceWorkerOptions, indexHtmlOptions, tailwindConfiguration, diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json index 0c184a854d5d..fd5a88b9f496 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json @@ -68,8 +68,7 @@ }, { "type": "string", - "description": "The file to include.", - "pattern": "\\.[cm]?jsx?$" + "description": "The JavaScript/TypeScript file or package containing the file to include." } ] } diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/options/scripts_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/options/scripts_spec.ts new file mode 100644 index 000000000000..66195582c1cf --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/options/scripts_spec.ts @@ -0,0 +1,438 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { buildEsbuildBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildEsbuildBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "scripts"', () => { + beforeEach(async () => { + // Application code is not needed for scripts tests + await harness.writeFile('src/main.ts', 'console.log("TESTING");'); + }); + + it('supports an empty array value', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('processes an empty script when optimizing', async () => { + await harness.writeFile('src/test-script-a.js', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + }, + scripts: ['src/test-script-a.js'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').toExist(); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + describe('shorthand syntax', () => { + it('processes a single script into a single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts into a single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + await harness.writeFile('src/test-script-b.js', 'console.log("b");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js', 'src/test-script-b.js'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').content.toContain('console.log("a")'); + harness.expectFile('dist/scripts.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('preserves order of multiple scripts in single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + await harness.writeFile('src/test-script-b.js', 'console.log("b");'); + await harness.writeFile('src/test-script-c.js', 'console.log("c");'); + await harness.writeFile('src/test-script-d.js', 'console.log("d");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + 'src/test-script-c.js', + 'src/test-script-d.js', + 'src/test-script-b.js', + 'src/test-script-a.js', + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/scripts.js') + .content.toMatch( + /console\.log\("c"\)[;\s]+console\.log\("d"\)[;\s]+console\.log\("b"\)[;\s]+console\.log\("a"\)/, + ); + }); + + it('fails and shows an error if script does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(`Could not resolve "src/test-script-a.js"`), + }), + ); + + harness.expectFile('dist/scripts.js').toNotExist(); + }); + + it('shows the output script as a chunk entry in the logging output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js'], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/scripts\.js.+\d+ bytes/) }), + ); + }); + }); + + describe('longhand syntax', () => { + it('processes a single script into a single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes a single script into a single output named with bundleName', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: 'extra' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('uses default bundleName when bundleName is empty string', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: '' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts with different bundleNames into separate outputs', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-a.js', bundleName: 'extra' }, + { input: 'src/test-script-b.js', bundleName: 'other' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.js').content.toContain('console.log("a")'); + harness.expectFile('dist/other.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts with no bundleName into a single output', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js' }, { input: 'src/test-script-b.js' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').content.toContain('console.log("a")'); + harness.expectFile('dist/scripts.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts with same bundleName into a single output', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-a.js', bundleName: 'extra' }, + { input: 'src/test-script-b.js', bundleName: 'extra' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.js').content.toContain('console.log("a")'); + harness.expectFile('dist/extra.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('preserves order of multiple scripts in single output', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + 'src/test-script-c.js': 'console.log("c");', + 'src/test-script-d.js': 'console.log("d");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-c.js' }, + { input: 'src/test-script-d.js' }, + { input: 'src/test-script-b.js' }, + { input: 'src/test-script-a.js' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/scripts.js') + .content.toMatch( + /console\.log\("c"\)[;\s]+console\.log\("d"\)[;\s]+console\.log\("b"\)[;\s]+console\.log\("a"\)/, + ); + }); + + it('preserves order of multiple scripts with different bundleNames', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + 'src/test-script-c.js': 'console.log("c");', + 'src/test-script-d.js': 'console.log("d");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-c.js', bundleName: 'other' }, + { input: 'src/test-script-d.js', bundleName: 'extra' }, + { input: 'src/test-script-b.js', bundleName: 'extra' }, + { input: 'src/test-script-a.js', bundleName: 'other' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/other.js') + .content.toMatch(/console\.log\("c"\)[;\s]+console\.log\("a"\)/); + harness + .expectFile('dist/extra.js') + .content.toMatch(/console\.log\("d"\)[;\s]+console\.log\("b"\)/); + harness + .expectFile('dist/index.html') + .content.toMatch( + /'); + }); + + it('does not add script element to index when inject is false', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', inject: false }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + // `inject: false` causes the bundleName to be the input file name + harness.expectFile('dist/test-script-a.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.not.toContain(''); + }); + + it('does not add script element to index with bundleName when inject is false', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: 'extra', inject: false }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.not.toContain(''); + }); + + it('shows the output script as a chunk entry in the logging output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js' }], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/scripts\.js.+\d+ bytes/) }), + ); + }); + + it('shows the output script as a chunk entry with bundleName in the logging output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: 'extra' }], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/extra\.js.+\d+ bytes/) }), + ); + }); + }); + }); +});