Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): support JIT compilation with esb…
Browse files Browse the repository at this point in the history
…uild

When using the experimental esbuild-based browser application builder, the `aot` build option
can now be set to `false` to enable JIT compilation mode. The JIT mode compilation operates
in a similar fashion to the Webpack-based builder in JIT mode. All external Component stylesheet
and template references are converted to static import statements and then the content is bundled
as text. All inline styles are also processed in this way as well to support inline style languages
such as Sass. This approach also has the advantage of minimizing the processing necessary during rebuilds.
In JIT watch mode, TypeScript code does not need to be reprocessed if only an external stylesheet
or template is changed.
  • Loading branch information
clydin authored and angular-robot[bot] committed Feb 3, 2023
1 parent fac1e58 commit 8cf0d17
Show file tree
Hide file tree
Showing 10 changed files with 674 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* @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 assert from 'node:assert';
import ts from 'typescript';
import { AngularCompilation } from '../angular-compilation';
import { AngularHostOptions, createAngularCompilerHost } from '../angular-host';
import { profileSync } from '../profiling';
import { createJitResourceTransformer } from './jit-resource-transformer';

class JitCompilationState {
constructor(
public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram,
public readonly constructorParametersDownlevelTransform: ts.TransformerFactory<ts.SourceFile>,
public readonly replaceResourcesTransform: ts.TransformerFactory<ts.SourceFile>,
) {}
}

export interface EmitFileResult {
content?: string;
map?: string;
dependencies: readonly string[];
}
export type FileEmitter = (file: string) => Promise<EmitFileResult | undefined>;

export class JitCompilation {
#state?: JitCompilationState;

async initialize(
rootNames: string[],
compilerOptions: ts.CompilerOptions,
hostOptions: AngularHostOptions,
configurationDiagnostics?: ts.Diagnostic[],
): Promise<{ affectedFiles: ReadonlySet<ts.SourceFile> }> {
// Dynamically load the Angular compiler CLI package
const { constructorParametersDownlevelTransform } = await AngularCompilation.loadCompilerCli();

// Create Angular compiler host
const host = createAngularCompilerHost(compilerOptions, hostOptions);

// Create the TypeScript Program
const typeScriptProgram = profileSync('TS_CREATE_PROGRAM', () =>
ts.createEmitAndSemanticDiagnosticsBuilderProgram(
rootNames,
compilerOptions,
host,
this.#state?.typeScriptProgram,
configurationDiagnostics,
),
);

const affectedFiles = profileSync('TS_FIND_AFFECTED', () =>
findAffectedFiles(typeScriptProgram),
);

this.#state = new JitCompilationState(
typeScriptProgram,
constructorParametersDownlevelTransform(typeScriptProgram.getProgram()),
createJitResourceTransformer(() => typeScriptProgram.getProgram().getTypeChecker()),
);

return { affectedFiles };
}

*collectDiagnostics(): Iterable<ts.Diagnostic> {
assert(this.#state, 'Compilation must be initialized prior to collecting diagnostics.');
const { typeScriptProgram } = this.#state;

// Collect program level diagnostics
yield* typeScriptProgram.getConfigFileParsingDiagnostics();
yield* typeScriptProgram.getOptionsDiagnostics();
yield* typeScriptProgram.getGlobalDiagnostics();
yield* profileSync('NG_DIAGNOSTICS_SYNTACTIC', () =>
typeScriptProgram.getSyntacticDiagnostics(),
);
yield* profileSync('NG_DIAGNOSTICS_SEMANTIC', () => typeScriptProgram.getSemanticDiagnostics());
}

createFileEmitter(onAfterEmit?: (sourceFile: ts.SourceFile) => void): FileEmitter {
assert(this.#state, 'Compilation must be initialized prior to emitting files.');
const {
typeScriptProgram,
constructorParametersDownlevelTransform,
replaceResourcesTransform,
} = this.#state;

const transformers = {
before: [replaceResourcesTransform, constructorParametersDownlevelTransform],
};

return async (file: string) => {
const sourceFile = typeScriptProgram.getSourceFile(file);
if (!sourceFile) {
return undefined;
}

let content: string | undefined;
typeScriptProgram.emit(
sourceFile,
(filename, data) => {
if (/\.[cm]?js$/.test(filename)) {
content = data;
}
},
undefined /* cancellationToken */,
undefined /* emitOnlyDtsFiles */,
transformers,
);

onAfterEmit?.(sourceFile);

return { content, dependencies: [] };
};
}
}

function findAffectedFiles(
builder: ts.EmitAndSemanticDiagnosticsBuilderProgram,
): Set<ts.SourceFile> {
const affectedFiles = new Set<ts.SourceFile>();

let result;
while ((result = builder.getSemanticDiagnosticsOfNextAffectedFile())) {
affectedFiles.add(result.affected as ts.SourceFile);
}

return affectedFiles;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* @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 { OutputFile, PluginBuild } from 'esbuild';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets';
import {
JIT_NAMESPACE_REGEXP,
JIT_STYLE_NAMESPACE,
JIT_TEMPLATE_NAMESPACE,
parseJitUri,
} from './uri';

/**
* Loads/extracts the contents from a load callback Angular JIT entry.
* An Angular JIT entry represents either a file path for a component resource or base64
* encoded data for an inline component resource.
* @param entry The value that represents content to load.
* @param root The absolute path for the root of the build (typically the workspace root).
* @param skipRead If true, do not attempt to read the file; if false, read file content from disk.
* This option has no effect if the entry does not originate from a file. Defaults to false.
* @returns An object containing the absolute path of the contents and optionally the actual contents.
* For inline entries the contents will always be provided.
*/
async function loadEntry(
entry: string,
root: string,
skipRead?: boolean,
): Promise<{ path: string; contents?: string }> {
if (entry.startsWith('file:')) {
const specifier = path.join(root, entry.slice(5));

return {
path: specifier,
contents: skipRead ? undefined : await readFile(specifier, 'utf-8'),
};
} else if (entry.startsWith('inline:')) {
const [importer, data] = entry.slice(7).split(';', 2);

return {
path: path.join(root, importer),
contents: Buffer.from(data, 'base64').toString(),
};
} else {
throw new Error('Invalid data for Angular JIT entry.');
}
}

/**
* Sets up esbuild resolve and load callbacks to support Angular JIT mode processing
* for both Component stylesheets and templates. These callbacks work alongside the JIT
* resource TypeScript transformer to convert and then bundle Component resources as
* static imports.
* @param build An esbuild {@link PluginBuild} instance used to add callbacks.
* @param styleOptions The options to use when bundling stylesheets.
* @param stylesheetResourceFiles An array where stylesheet resources will be added.
*/
export function setupJitPluginCallbacks(
build: PluginBuild,
styleOptions: BundleStylesheetOptions & { inlineStyleLanguage: string },
stylesheetResourceFiles: OutputFile[],
): void {
const root = build.initialOptions.absWorkingDir ?? '';

// Add a resolve callback to capture and parse any JIT URIs that were added by the
// JIT resource TypeScript transformer.
// Resources originating from a file are resolved as relative from the containing file (importer).
build.onResolve({ filter: JIT_NAMESPACE_REGEXP }, (args) => {
const parsed = parseJitUri(args.path);
if (!parsed) {
return undefined;
}

const { namespace, origin, specifier } = parsed;

if (origin === 'file') {
return {
// Use a relative path to prevent fully resolved paths in the metafile (JSON stats file).
// This is only necessary for custom namespaces. esbuild will handle the file namespace.
path: 'file:' + path.relative(root, path.join(path.dirname(args.importer), specifier)),
namespace,
};
} else {
// Inline data may need the importer to resolve imports/references within the content
const importer = path.relative(root, args.importer);

return {
path: `inline:${importer};${specifier}`,
namespace,
};
}
});

// Add a load callback to handle Component stylesheets (both inline and external)
build.onLoad({ filter: /./, namespace: JIT_STYLE_NAMESPACE }, async (args) => {
// skipRead is used here because the stylesheet bundling will read a file stylesheet
// directly either via a preprocessor or esbuild itself.
const entry = await loadEntry(args.path, root, true /* skipRead */);

const { contents, resourceFiles, errors, warnings } = await bundleComponentStylesheet(
styleOptions.inlineStyleLanguage,
// The `data` parameter is only needed for a stylesheet if it was inline
entry.contents ?? '',
entry.path,
entry.contents !== undefined,
styleOptions,
);

stylesheetResourceFiles.push(...resourceFiles);

return {
errors,
warnings,
contents,
loader: 'text',
};
});

// Add a load callback to handle Component templates
// NOTE: While this callback supports both inline and external templates, the transformer
// currently only supports generating URIs for external templates.
build.onLoad({ filter: /./, namespace: JIT_TEMPLATE_NAMESPACE }, async (args) => {
const { contents } = await loadEntry(args.path, root);

return {
contents,
loader: 'text',
};
});
}
Loading

0 comments on commit 8cf0d17

Please sign in to comment.