Skip to content

Commit 3d1c09b

Browse files
committed
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.
1 parent ee5763d commit 3d1c09b

File tree

3 files changed

+102
-8
lines changed

3 files changed

+102
-8
lines changed

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,46 @@ function createCodeBundleOptions(
418418
},
419419
};
420420

421+
if (options.externalPackages) {
422+
// Add a plugin that marks any resolved path as external if it is within a node modules directory.
423+
// This is used instead of the esbuild `packages` option to avoid marking bare specifiers that use
424+
// tsconfig path mapping to resolve to a workspace relative path. This is common for monorepos that
425+
// contain libraries that are built along with the application. These libraries should not be considered
426+
// external even though the imports appear to be packages.
427+
const EXTERNAL_PACKAGE_RESOLUTION = Symbol('EXTERNAL_PACKAGE_RESOLUTION');
428+
buildOptions.plugins ??= [];
429+
buildOptions.plugins.push({
430+
name: 'angular-external-packages',
431+
setup(build) {
432+
build.onResolve({ filter: /./ }, async (args) => {
433+
if (args.pluginData?.[EXTERNAL_PACKAGE_RESOLUTION]) {
434+
return null;
435+
}
436+
437+
const { importer, kind, resolveDir, namespace, pluginData = {} } = args;
438+
pluginData[EXTERNAL_PACKAGE_RESOLUTION] = true;
439+
440+
const result = await build.resolve(args.path, {
441+
importer,
442+
kind,
443+
namespace,
444+
pluginData,
445+
resolveDir,
446+
});
447+
448+
if (result.path && /[\\/]node_modules[\\/]/.test(result.path)) {
449+
return {
450+
path: args.path,
451+
external: true,
452+
};
453+
}
454+
455+
return result;
456+
});
457+
},
458+
});
459+
}
460+
421461
const polyfills = options.polyfills ? [...options.polyfills] : [];
422462
if (jit) {
423463
polyfills.push('@angular/compiler');

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ interface InternalOptions {
3131

3232
/** File extension to use for the generated output files. */
3333
outExtension?: 'js' | 'mjs';
34+
35+
/**
36+
* Indicates whether all node packages should be marked as external.
37+
* Currently used by the dev-server to support prebundling.
38+
*/
39+
externalPackages?: boolean;
3440
}
3541

3642
/** Full set of options for `browser-esbuild` builder. */
@@ -180,6 +186,7 @@ export async function normalizeOptions(
180186
verbose,
181187
watch,
182188
progress,
189+
externalPackages,
183190
} = options;
184191

185192
// Return all the normalized options
@@ -197,6 +204,7 @@ export async function normalizeOptions(
197204
polyfills: polyfills === undefined || Array.isArray(polyfills) ? polyfills : [polyfills],
198205
poll,
199206
progress: progress ?? true,
207+
externalPackages,
200208
// If not explicitly set, default to the Node.js process argument
201209
preserveSymlinks: preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks'),
202210
stylePreprocessorOptions,

packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import { readFile } from 'node:fs/promises';
1616
import type { AddressInfo } from 'node:net';
1717
import path from 'node:path';
1818
import { InlineConfig, ViteDevServer, createServer, normalizePath } from 'vite';
19-
import { buildEsbuildBrowser } from '../browser-esbuild';
19+
import { buildEsbuildBrowserInternal } from '../browser-esbuild';
20+
import { JavaScriptTransformer } from '../browser-esbuild/javascript-transformer';
21+
import { BrowserEsbuildOptions } from '../browser-esbuild/options';
2022
import type { Schema as BrowserBuilderOptions } from '../browser-esbuild/schema';
2123
import { loadProxyConfiguration, normalizeProxyConfiguration } from './load-proxy-config';
2224
import type { NormalizedDevServerOptions } from './options';
@@ -52,7 +54,9 @@ export async function* serveWithVite(
5254
verbose: serverOptions.verbose,
5355
} as json.JsonObject & BrowserBuilderOptions,
5456
builderName,
55-
)) as json.JsonObject & BrowserBuilderOptions;
57+
)) as json.JsonObject & BrowserEsbuildOptions;
58+
// Set all packages as external to support Vite's prebundle caching
59+
browserOptions.externalPackages = serverOptions.cacheOptions.enabled;
5660

5761
if (serverOptions.servePath === undefined && browserOptions.baseHref !== undefined) {
5862
serverOptions.servePath = browserOptions.baseHref;
@@ -63,7 +67,9 @@ export async function* serveWithVite(
6367
const generatedFiles = new Map<string, OutputFileRecord>();
6468
const assetFiles = new Map<string, string>();
6569
// TODO: Switch this to an architect schedule call when infrastructure settings are supported
66-
for await (const result of buildEsbuildBrowser(browserOptions, context, { write: false })) {
70+
for await (const result of buildEsbuildBrowserInternal(browserOptions, context, {
71+
write: false,
72+
})) {
6773
assert(result.outputFiles, 'Builder did not provide result files.');
6874

6975
// Analyze result files for changes
@@ -96,7 +102,13 @@ export async function* serveWithVite(
96102
}
97103
} else {
98104
// Setup server and start listening
99-
const serverConfiguration = await setupServer(serverOptions, generatedFiles, assetFiles);
105+
const serverConfiguration = await setupServer(
106+
serverOptions,
107+
generatedFiles,
108+
assetFiles,
109+
browserOptions.preserveSymlinks,
110+
browserOptions.externalDependencies,
111+
);
100112
server = await createServer(serverConfiguration);
101113

102114
await server.listen();
@@ -173,10 +185,13 @@ function analyzeResultFiles(
173185
}
174186
}
175187

188+
// eslint-disable-next-line max-lines-per-function
176189
export async function setupServer(
177190
serverOptions: NormalizedDevServerOptions,
178191
outputFiles: Map<string, OutputFileRecord>,
179192
assets: Map<string, string>,
193+
preserveSymlinks: boolean | undefined,
194+
prebundleExclude: string[] | undefined,
180195
): Promise<InlineConfig> {
181196
const proxy = await loadProxyConfiguration(
182197
serverOptions.workspaceRoot,
@@ -199,6 +214,10 @@ export async function setupServer(
199214
devSourcemap: true,
200215
},
201216
base: serverOptions.servePath,
217+
resolve: {
218+
mainFields: ['es2020', 'browser', 'module', 'main'],
219+
preserveSymlinks,
220+
},
202221
server: {
203222
port: serverOptions.port,
204223
strictPort: true,
@@ -236,12 +255,13 @@ export async function setupServer(
236255
return;
237256
}
238257

258+
const code = Buffer.from(codeContents).toString('utf-8');
239259
const mapContents = outputFiles.get(file + '.map')?.contents;
240260

241261
return {
242262
// Remove source map URL comments from the code if a sourcemap is present.
243263
// Vite will inline and add an additional sourcemap URL for the sourcemap.
244-
code: Buffer.from(codeContents).toString('utf-8'),
264+
code: mapContents ? code.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '') : code,
245265
map: mapContents && Buffer.from(mapContents).toString('utf-8'),
246266
};
247267
},
@@ -276,7 +296,7 @@ export async function setupServer(
276296
// Resource files are handled directly.
277297
// Global stylesheets (CSS files) are currently considered resources to workaround
278298
// dev server sourcemap issues with stylesheets.
279-
if (extension !== '.html') {
299+
if (extension !== '.js' && extension !== '.html') {
280300
const outputFile = outputFiles.get(pathname);
281301
if (outputFile) {
282302
const mimeType = lookupMimeType(extension);
@@ -345,8 +365,34 @@ export async function setupServer(
345365
},
346366
],
347367
optimizeDeps: {
348-
// TODO: Consider enabling for known safe dependencies (@angular/* ?)
349-
disabled: true,
368+
// Only enable with caching since it causes prebundle dependencies to be cached
369+
disabled: !serverOptions.cacheOptions.enabled,
370+
// Exclude any provided dependencies (currently build defined externals)
371+
exclude: prebundleExclude,
372+
// Skip automatic file-based entry point discovery
373+
include: [],
374+
// Add an esbuild plugin to run the Angular linker on dependencies
375+
esbuildOptions: {
376+
plugins: [
377+
{
378+
name: 'angular-vite-optimize-deps',
379+
setup(build) {
380+
const transformer = new JavaScriptTransformer(
381+
{ sourcemap: !!build.initialOptions.sourcemap },
382+
1,
383+
);
384+
385+
build.onLoad({ filter: /\.[cm]?js$/ }, async (args) => {
386+
return {
387+
contents: await transformer.transformFile(args.path),
388+
loader: 'js',
389+
};
390+
});
391+
build.onEnd(() => transformer.close());
392+
},
393+
},
394+
],
395+
},
350396
},
351397
};
352398

0 commit comments

Comments
 (0)