Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): add initial incremental code reb…
Browse files Browse the repository at this point in the history
…uilding to esbuild builder

The experimental esbuild-based browser application builder will now support incremental JavaScript
bundling when run in watch mode via the `watch` option. This initial implementation integrates the
esbuild incremental rebuild functionality. TypeScript source file caching has also been added to
improve the rebuild initialization time for the TypeScript and Angular compilation steps.
This initial support is not yet fully optimized and additional work is planned to further improve
the rebuild performance.
  • Loading branch information
clydin committed Oct 5, 2022
1 parent 301b566 commit 67324b3
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 20 deletions.
Expand Up @@ -18,6 +18,7 @@ import type {
PluginBuild,
} from 'esbuild';
import { promises as fs } from 'fs';
import { platform } from 'os';
import * as path from 'path';
import ts from 'typescript';
import angularApplicationPreset from '../../babel/presets/application';
Expand Down Expand Up @@ -120,12 +121,33 @@ function convertTypeScriptDiagnostic(
return message;
}

const USING_WINDOWS = platform() === 'win32';
const WINDOWS_SEP_REGEXP = new RegExp(`\\${path.win32.sep}`, 'g');

export class SourceFileCache extends Map<string, ts.SourceFile> {
readonly modifiedFiles = new Set<string>();

invalidate(files: Iterable<string>): void {
this.modifiedFiles.clear();
for (let file of files) {
// Normalize separators to allow matching TypeScript Host paths
if (USING_WINDOWS) {
file = file.replace(WINDOWS_SEP_REGEXP, path.posix.sep);
}

this.delete(file);
this.modifiedFiles.add(file);
}
}
}

export interface CompilerPluginOptions {
sourcemap: boolean;
tsconfig: string;
advancedOptimizations?: boolean;
thirdPartySourcemaps?: boolean;
fileReplacements?: Record<string, string>;
sourceFileCache?: SourceFileCache;
}

// This is a non-watch version of the compiler code from `@ngtools/webpack` augmented for esbuild
Expand Down Expand Up @@ -262,6 +284,7 @@ export function createCompilerPlugin(

// Temporary deep import for host augmentation support
const {
augmentHostWithCaching,
augmentHostWithReplacements,
augmentProgramWithVersioning,
} = require('@ngtools/webpack/src/ivy/host');
Expand All @@ -271,6 +294,15 @@ export function createCompilerPlugin(
augmentHostWithReplacements(host, pluginOptions.fileReplacements);
}

// Augment TypeScript Host with source file caching if provided
if (pluginOptions.sourceFileCache) {
augmentHostWithCaching(host, pluginOptions.sourceFileCache);
// Allow the AOT compiler to request the set of changed templates and styles
(host as CompilerHost).getModifiedResourceFiles = function () {
return pluginOptions.sourceFileCache?.modifiedFiles;
};
}

// Create the Angular specific program that contains the Angular compiler
const angularProgram = new compilerCli.NgtscProgram(
rootNames,
Expand Down
Expand Up @@ -9,6 +9,7 @@
import { BuilderContext } from '@angular-devkit/architect';
import {
BuildFailure,
BuildInvalidate,
BuildOptions,
BuildResult,
Message,
Expand All @@ -32,20 +33,25 @@ export function isEsBuildFailure(value: unknown): value is BuildFailure {
* All builds use the `write` option with a value of `false` to allow for the output files
* build result array to be populated.
*
* @param options The esbuild options object to use when building.
* @param optionsOrInvalidate The esbuild options object to use when building or the invalidate object
* returned from an incremental build to perform an additional incremental build.
* @returns If output files are generated, the full esbuild BuildResult; if not, the
* warnings and errors for the attempted build.
*/
export async function bundle(
options: BuildOptions,
optionsOrInvalidate: BuildOptions | BuildInvalidate,
): Promise<
(BuildResult & { outputFiles: OutputFile[] }) | (BuildFailure & { outputFiles?: never })
> {
try {
return await build({
...options,
write: false,
});
if (typeof optionsOrInvalidate === 'function') {
return (await optionsOrInvalidate()) as BuildResult & { outputFiles: OutputFile[] };
} else {
return await build({
...optionsOrInvalidate,
write: false,
});
}
} catch (failure) {
// Build failures will throw an exception which contains errors/warnings
if (isEsBuildFailure(failure)) {
Expand Down
Expand Up @@ -8,7 +8,7 @@

import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import * as assert from 'assert';
import type { Message, OutputFile } from 'esbuild';
import type { BuildInvalidate, BuildOptions, Message, OutputFile } from 'esbuild';
import * as fs from 'fs/promises';
import * as path from 'path';
import { deleteOutputDir } from '../../utils';
Expand All @@ -19,18 +19,56 @@ import { FileInfo } from '../../utils/index-file/augment-index-html';
import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator';
import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker';
import { getSupportedBrowsers } from '../../utils/supported-browsers';
import { createCompilerPlugin } from './compiler-plugin';
import { SourceFileCache, createCompilerPlugin } from './compiler-plugin';
import { bundle, logMessages } from './esbuild';
import { logExperimentalWarnings } from './experimental-warnings';
import { NormalizedBrowserOptions, normalizeOptions } from './options';
import { Schema as BrowserBuilderOptions } from './schema';
import { bundleStylesheetText } from './stylesheets';
import { createWatcher } from './watcher';
import { ChangedFiles, createWatcher } from './watcher';

interface RebuildState {
codeRebuild?: BuildInvalidate;
codeBundleCache?: SourceFileCache;
fileChanges: ChangedFiles;
}

/**
* Represents the result of a single builder execute call.
*/
class ExecutionResult {
constructor(
private success: boolean,
private codeRebuild?: BuildInvalidate,
private codeBundleCache?: SourceFileCache,
) {}

get output() {
return {
success: this.success,
};
}

createRebuildState(fileChanges: ChangedFiles): RebuildState {
this.codeBundleCache?.invalidate([...fileChanges.modified, ...fileChanges.removed]);

return {
codeRebuild: this.codeRebuild,
codeBundleCache: this.codeBundleCache,
fileChanges,
};
}

dispose(): void {
this.codeRebuild?.dispose();
}
}

async function execute(
options: NormalizedBrowserOptions,
context: BuilderContext,
): Promise<BuilderOutput> {
rebuildState?: RebuildState,
): Promise<ExecutionResult> {
const startTime = Date.now();

const {
Expand All @@ -47,9 +85,13 @@ async function execute(
getSupportedBrowsers(projectRoot, context.logger),
);

const codeBundleCache = options.watch
? rebuildState?.codeBundleCache ?? new SourceFileCache()
: undefined;

const [codeResults, styleResults] = await Promise.all([
// Execute esbuild to bundle the application code
bundleCode(options, target),
bundle(rebuildState?.codeRebuild ?? createCodeBundleOptions(options, target, codeBundleCache)),
// Execute esbuild to bundle the global stylesheets
bundleGlobalStylesheets(options, target),
]);
Expand All @@ -62,7 +104,7 @@ async function execute(

// Return if the bundling failed to generate output files or there are errors
if (!codeResults.outputFiles || codeResults.errors.length) {
return { success: false };
return new ExecutionResult(false, rebuildState?.codeRebuild, codeBundleCache);
}

// Structure the code bundling output files
Expand Down Expand Up @@ -93,7 +135,7 @@ async function execute(

// Return if the global stylesheet bundling has errors
if (styleResults.errors.length) {
return { success: false };
return new ExecutionResult(false, codeResults.rebuild, codeBundleCache);
}

// Generate index HTML file
Expand Down Expand Up @@ -160,13 +202,13 @@ async function execute(
} catch (error) {
context.logger.error(error instanceof Error ? error.message : `${error}`);

return { success: false };
return new ExecutionResult(false, codeResults.rebuild, codeBundleCache);
}
}

context.logger.info(`Complete. [${(Date.now() - startTime) / 1000} seconds]`);

return { success: true };
return new ExecutionResult(true, codeResults.rebuild, codeBundleCache);
}

function createOutputFileFromText(path: string, text: string): OutputFile {
Expand All @@ -179,7 +221,11 @@ function createOutputFileFromText(path: string, text: string): OutputFile {
};
}

async function bundleCode(options: NormalizedBrowserOptions, target: string[]) {
function createCodeBundleOptions(
options: NormalizedBrowserOptions,
target: string[],
sourceFileCache?: SourceFileCache,
): BuildOptions {
const {
workspaceRoot,
entryPoints,
Expand All @@ -194,9 +240,10 @@ async function bundleCode(options: NormalizedBrowserOptions, target: string[]) {
advancedOptimizations,
} = options;

return bundle({
return {
absWorkingDir: workspaceRoot,
bundle: true,
incremental: options.watch,
format: 'esm',
entryPoints,
entryNames: outputNames.bundles,
Expand Down Expand Up @@ -234,6 +281,7 @@ async function bundleCode(options: NormalizedBrowserOptions, target: string[]) {
tsconfig,
advancedOptimizations,
fileReplacements,
sourceFileCache,
},
// Component stylesheet options
{
Expand All @@ -255,7 +303,7 @@ async function bundleCode(options: NormalizedBrowserOptions, target: string[]) {
...(optimizationOptions.scripts ? { 'ngDevMode': 'false' } : undefined),
'ngJitMode': 'false',
},
});
};
}

async function bundleGlobalStylesheets(options: NormalizedBrowserOptions, target: string[]) {
Expand Down Expand Up @@ -383,13 +431,16 @@ export async function* buildEsbuildBrowser(
}

// Initial build
yield await execute(normalizedOptions, context);
let result = await execute(normalizedOptions, context);
yield result.output;

// Finish if watch mode is not enabled
if (!initialOptions.watch) {
return;
}

context.logger.info('Watch mode enabled. Watching for file changes...');

// Setup a watcher
const watcher = createWatcher({
polling: typeof initialOptions.poll === 'number',
Expand All @@ -416,10 +467,14 @@ export async function* buildEsbuildBrowser(
context.logger.info(changes.toDebugString());
}

yield await execute(normalizedOptions, context);
result = await execute(normalizedOptions, context, result.createRebuildState(changes));
yield result.output;
}
} finally {
// Stop the watcher
await watcher.close();
// Cleanup incremental rebuild state
result.dispose();
}
}

Expand Down
Expand Up @@ -133,10 +133,12 @@ export async function normalizeOptions(
buildOptimizer,
crossOrigin,
externalDependencies,
poll,
preserveSymlinks,
stylePreprocessorOptions,
subresourceIntegrity,
verbose,
watch,
} = options;

// Return all the normalized options
Expand All @@ -145,10 +147,12 @@ export async function normalizeOptions(
baseHref,
crossOrigin,
externalDependencies,
poll,
preserveSymlinks,
stylePreprocessorOptions,
subresourceIntegrity,
verbose,
watch,
workspaceRoot,
entryPoints,
optimizationOptions,
Expand Down

0 comments on commit 67324b3

Please sign in to comment.