-
Notifications
You must be signed in to change notification settings - Fork 12k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(@angular-devkit/build-angular): support JIT compilation with esb…
…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
1 parent
fac1e58
commit 8cf0d17
Showing
10 changed files
with
674 additions
and
24 deletions.
There are no files selected for viewing
133 changes: 133 additions & 0 deletions
133
...ages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-compilation.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
136 changes: 136 additions & 0 deletions
136
...angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-plugin-callbacks.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}; | ||
}); | ||
} |
Oops, something went wrong.