From 3d1c09b235bf1db0d031c36fdc68ab99359b34b1 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 1 May 2023 13:09:30 -0400 Subject: [PATCH] feat(@angular-devkit/build-angular): support dev-server package prebundling with esbuild builder When using the development server with the esbuild-based browser application builder, the underlying Vite server will now prebundle packages present in an application. During the prebundling process, the Angular linker will also be invoked to ensure that APF packages are processed for AOT usage. The Vite prebundling also provides automatic persistent caching of processed packages. This allows reuse of processed packages across `ng serve` invocations. To support the use of prebundling at the development server level, all packages are considered external from the build level. The first time a package is used within an application there may be a short delay upon accessing the page as the package is processed. Due to the persistent nature of the prebundling, the `ng cache` command is used to control the use of the feature. Please note, however, disabling the cache will also disable TypeScript incremental compilation if not otherwise specifically disabled. --- .../src/builders/browser-esbuild/index.ts | 40 ++++++++++++ .../src/builders/browser-esbuild/options.ts | 8 +++ .../src/builders/dev-server/vite-server.ts | 62 ++++++++++++++++--- 3 files changed, 102 insertions(+), 8 deletions(-) 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 ea2019a88e91..1d04ff19c46b 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 @@ -418,6 +418,46 @@ function createCodeBundleOptions( }, }; + if (options.externalPackages) { + // Add a plugin that marks any resolved path as external if it is within a node modules directory. + // This is used instead of the esbuild `packages` option to avoid marking bare specifiers that use + // tsconfig path mapping to resolve to a workspace relative path. This is common for monorepos that + // contain libraries that are built along with the application. These libraries should not be considered + // external even though the imports appear to be packages. + const EXTERNAL_PACKAGE_RESOLUTION = Symbol('EXTERNAL_PACKAGE_RESOLUTION'); + buildOptions.plugins ??= []; + buildOptions.plugins.push({ + name: 'angular-external-packages', + setup(build) { + build.onResolve({ filter: /./ }, async (args) => { + if (args.pluginData?.[EXTERNAL_PACKAGE_RESOLUTION]) { + return null; + } + + const { importer, kind, resolveDir, namespace, pluginData = {} } = args; + pluginData[EXTERNAL_PACKAGE_RESOLUTION] = true; + + const result = await build.resolve(args.path, { + importer, + kind, + namespace, + pluginData, + resolveDir, + }); + + if (result.path && /[\\/]node_modules[\\/]/.test(result.path)) { + return { + path: args.path, + external: true, + }; + } + + return result; + }); + }, + }); + } + const polyfills = options.polyfills ? [...options.polyfills] : []; if (jit) { polyfills.push('@angular/compiler'); 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 d18d1a0e8484..eca625eef575 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 @@ -31,6 +31,12 @@ interface InternalOptions { /** File extension to use for the generated output files. */ outExtension?: 'js' | 'mjs'; + + /** + * Indicates whether all node packages should be marked as external. + * Currently used by the dev-server to support prebundling. + */ + externalPackages?: boolean; } /** Full set of options for `browser-esbuild` builder. */ @@ -180,6 +186,7 @@ export async function normalizeOptions( verbose, watch, progress, + externalPackages, } = options; // Return all the normalized options @@ -197,6 +204,7 @@ export async function normalizeOptions( polyfills: polyfills === undefined || Array.isArray(polyfills) ? polyfills : [polyfills], poll, progress: progress ?? true, + externalPackages, // If not explicitly set, default to the Node.js process argument preserveSymlinks: preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks'), stylePreprocessorOptions, diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts index 412d692ce82f..4a5a7c320c6c 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts @@ -16,7 +16,9 @@ import { readFile } from 'node:fs/promises'; import type { AddressInfo } from 'node:net'; import path from 'node:path'; import { InlineConfig, ViteDevServer, createServer, normalizePath } from 'vite'; -import { buildEsbuildBrowser } from '../browser-esbuild'; +import { buildEsbuildBrowserInternal } from '../browser-esbuild'; +import { JavaScriptTransformer } from '../browser-esbuild/javascript-transformer'; +import { BrowserEsbuildOptions } from '../browser-esbuild/options'; import type { Schema as BrowserBuilderOptions } from '../browser-esbuild/schema'; import { loadProxyConfiguration, normalizeProxyConfiguration } from './load-proxy-config'; import type { NormalizedDevServerOptions } from './options'; @@ -52,7 +54,9 @@ export async function* serveWithVite( verbose: serverOptions.verbose, } as json.JsonObject & BrowserBuilderOptions, builderName, - )) as json.JsonObject & BrowserBuilderOptions; + )) as json.JsonObject & BrowserEsbuildOptions; + // Set all packages as external to support Vite's prebundle caching + browserOptions.externalPackages = serverOptions.cacheOptions.enabled; if (serverOptions.servePath === undefined && browserOptions.baseHref !== undefined) { serverOptions.servePath = browserOptions.baseHref; @@ -63,7 +67,9 @@ export async function* serveWithVite( const generatedFiles = new Map(); const assetFiles = new Map(); // TODO: Switch this to an architect schedule call when infrastructure settings are supported - for await (const result of buildEsbuildBrowser(browserOptions, context, { write: false })) { + for await (const result of buildEsbuildBrowserInternal(browserOptions, context, { + write: false, + })) { assert(result.outputFiles, 'Builder did not provide result files.'); // Analyze result files for changes @@ -96,7 +102,13 @@ export async function* serveWithVite( } } else { // Setup server and start listening - const serverConfiguration = await setupServer(serverOptions, generatedFiles, assetFiles); + const serverConfiguration = await setupServer( + serverOptions, + generatedFiles, + assetFiles, + browserOptions.preserveSymlinks, + browserOptions.externalDependencies, + ); server = await createServer(serverConfiguration); await server.listen(); @@ -173,10 +185,13 @@ function analyzeResultFiles( } } +// eslint-disable-next-line max-lines-per-function export async function setupServer( serverOptions: NormalizedDevServerOptions, outputFiles: Map, assets: Map, + preserveSymlinks: boolean | undefined, + prebundleExclude: string[] | undefined, ): Promise { const proxy = await loadProxyConfiguration( serverOptions.workspaceRoot, @@ -199,6 +214,10 @@ export async function setupServer( devSourcemap: true, }, base: serverOptions.servePath, + resolve: { + mainFields: ['es2020', 'browser', 'module', 'main'], + preserveSymlinks, + }, server: { port: serverOptions.port, strictPort: true, @@ -236,12 +255,13 @@ export async function setupServer( return; } + const code = Buffer.from(codeContents).toString('utf-8'); const mapContents = outputFiles.get(file + '.map')?.contents; return { // Remove source map URL comments from the code if a sourcemap is present. // Vite will inline and add an additional sourcemap URL for the sourcemap. - code: Buffer.from(codeContents).toString('utf-8'), + code: mapContents ? code.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '') : code, map: mapContents && Buffer.from(mapContents).toString('utf-8'), }; }, @@ -276,7 +296,7 @@ export async function setupServer( // Resource files are handled directly. // Global stylesheets (CSS files) are currently considered resources to workaround // dev server sourcemap issues with stylesheets. - if (extension !== '.html') { + if (extension !== '.js' && extension !== '.html') { const outputFile = outputFiles.get(pathname); if (outputFile) { const mimeType = lookupMimeType(extension); @@ -345,8 +365,34 @@ export async function setupServer( }, ], optimizeDeps: { - // TODO: Consider enabling for known safe dependencies (@angular/* ?) - disabled: true, + // Only enable with caching since it causes prebundle dependencies to be cached + disabled: !serverOptions.cacheOptions.enabled, + // Exclude any provided dependencies (currently build defined externals) + exclude: prebundleExclude, + // Skip automatic file-based entry point discovery + include: [], + // Add an esbuild plugin to run the Angular linker on dependencies + esbuildOptions: { + plugins: [ + { + name: 'angular-vite-optimize-deps', + setup(build) { + const transformer = new JavaScriptTransformer( + { sourcemap: !!build.initialOptions.sourcemap }, + 1, + ); + + build.onLoad({ filter: /\.[cm]?js$/ }, async (args) => { + return { + contents: await transformer.transformFile(args.path), + loader: 'js', + }; + }); + build.onEnd(() => transformer.close()); + }, + }, + ], + }, }, };