From 8bce80b91b953c391ef8e45fec7f887f8d8521aa Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 20 Sep 2023 14:13:25 -0400 Subject: [PATCH] feat(@angular-devkit/build-angular): initial support for application Web Worker discovery with esbuild When using the esbuild-based builders (application/browser-esbuild), Web Workers following the previously supported syntax as used in the Webpack-based builder will now be discovered. The worker entry points are not yet bundled or otherwise processed. Currently, a warning will be issued to notify that the worker will not be present in the built output. Additional upcoming changes will add the processing and bundling support for the workers. Web Worker syntax example: `new Worker(new URL('./my-worker-file', import.meta.url), { type: 'module' });` --- .../src/tools/esbuild/angular/angular-host.ts | 1 + .../angular/compilation/aot-compilation.ts | 10 +- .../angular/compilation/jit-compilation.ts | 10 +- .../tools/esbuild/angular/compiler-plugin.ts | 19 +++ .../esbuild/angular/web-worker-transformer.ts | 121 ++++++++++++++++++ 5 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/tools/esbuild/angular/web-worker-transformer.ts diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/angular/angular-host.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/angular-host.ts index 6b87759ae0f9..63ebf72f916f 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/angular/angular-host.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/angular-host.ts @@ -21,6 +21,7 @@ export interface AngularHostOptions { containingFile: string, stylesheetFile?: string, ): Promise; + processWebWorker(workerFile: string, containingFile: string): string; } // Temporary deep import for host augmentation support. diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/aot-compilation.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/aot-compilation.ts index 60eabae05315..1712ebc44fb0 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/aot-compilation.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/aot-compilation.ts @@ -15,6 +15,7 @@ import { createAngularCompilerHost, ensureSourceFileVersions, } from '../angular-host'; +import { createWorkerTransformer } from '../web-worker-transformer'; import { AngularCompilation, EmitFileResult } from './angular-compilation'; // Temporary deep import for transformer support @@ -28,6 +29,7 @@ class AngularCompilationState { public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, public readonly affectedFiles: ReadonlySet, public readonly templateDiagnosticsOptimization: ng.OptimizeFor, + public readonly webWorkerTransform: ts.TransformerFactory, public readonly diagnosticCache = new WeakMap(), ) {} @@ -97,6 +99,7 @@ export class AotCompilation extends AngularCompilation { typeScriptProgram, affectedFiles, affectedFiles.size === 1 ? OptimizeFor.SingleFile : OptimizeFor.WholeProgram, + createWorkerTransformer(hostOptions.processWebWorker.bind(hostOptions)), this.#state?.diagnosticCache, ); @@ -172,7 +175,7 @@ export class AotCompilation extends AngularCompilation { emitAffectedFiles(): Iterable { assert(this.#state, 'Angular compilation must be initialized prior to emitting files.'); - const { angularCompiler, compilerHost, typeScriptProgram } = this.#state; + const { angularCompiler, compilerHost, typeScriptProgram, webWorkerTransform } = this.#state; const buildInfoFilename = typeScriptProgram.getCompilerOptions().tsBuildInfoFile ?? '.tsbuildinfo'; @@ -195,7 +198,10 @@ export class AotCompilation extends AngularCompilation { emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents }); }; const transformers = mergeTransformers(angularCompiler.prepareEmit().transformers, { - before: [replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker())], + before: [ + replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()), + webWorkerTransform, + ], }); // TypeScript will loop until there are no more affected files in the program diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/jit-compilation.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/jit-compilation.ts index a6c0d5c7be1f..fe7d39fc6b6f 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/jit-compilation.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/jit-compilation.ts @@ -12,6 +12,7 @@ import ts from 'typescript'; import { profileSync } from '../../profiling'; import { AngularHostOptions, createAngularCompilerHost } from '../angular-host'; import { createJitResourceTransformer } from '../jit-resource-transformer'; +import { createWorkerTransformer } from '../web-worker-transformer'; import { AngularCompilation, EmitFileResult } from './angular-compilation'; class JitCompilationState { @@ -20,6 +21,7 @@ class JitCompilationState { public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, public readonly constructorParametersDownlevelTransform: ts.TransformerFactory, public readonly replaceResourcesTransform: ts.TransformerFactory, + public readonly webWorkerTransform: ts.TransformerFactory, ) {} } @@ -70,6 +72,7 @@ export class JitCompilation extends AngularCompilation { typeScriptProgram, constructorParametersDownlevelTransform(typeScriptProgram.getProgram()), createJitResourceTransformer(() => typeScriptProgram.getProgram().getTypeChecker()), + createWorkerTransformer(hostOptions.processWebWorker.bind(hostOptions)), ); const referencedFiles = typeScriptProgram @@ -100,6 +103,7 @@ export class JitCompilation extends AngularCompilation { typeScriptProgram, constructorParametersDownlevelTransform, replaceResourcesTransform, + webWorkerTransform, } = this.#state; const buildInfoFilename = typeScriptProgram.getCompilerOptions().tsBuildInfoFile ?? '.tsbuildinfo'; @@ -118,7 +122,11 @@ export class JitCompilation extends AngularCompilation { emittedFiles.push({ filename: sourceFiles[0].fileName, contents }); }; const transformers = { - before: [replaceResourcesTransform, constructorParametersDownlevelTransform], + before: [ + replaceResourcesTransform, + constructorParametersDownlevelTransform, + webWorkerTransform, + ], }; // TypeScript will loop until there are no more affected files in the program diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compiler-plugin.ts index 07489f8c41c8..894ba5c85fdf 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/compiler-plugin.ts @@ -180,6 +180,25 @@ export function createCompilerPlugin( return contents; }, + processWebWorker(workerFile, containingFile) { + // TODO: Implement bundling of the worker + // This temporarily issues a warning that workers are not yet processed. + (result.warnings ??= []).push({ + text: 'Processing of Web Worker files is not yet implemented.', + location: null, + notes: [ + { + text: `The worker entry point file '${workerFile}' found in '${path.relative( + styleOptions.workspaceRoot, + containingFile, + )}' will not be present in the output.`, + }, + ], + }); + + // Returning the original file prevents modification to the containing file + return workerFile; + }, }; // Initialize the Angular compilation for the current build. diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/angular/web-worker-transformer.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/web-worker-transformer.ts new file mode 100644 index 000000000000..13c78181c921 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/web-worker-transformer.ts @@ -0,0 +1,121 @@ +/** + * @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 ts from 'typescript'; + +/** + * Creates a TypeScript Transformer to process Worker and SharedWorker entry points and transform + * the URL instances to reference the built and bundled worker code. This uses a callback process + * similar to the component stylesheets to allow the main esbuild plugin to process files as needed. + * Unsupported worker expressions will be left in their origin form. + * @param getTypeChecker A function that returns a TypeScript TypeChecker instance for the program. + * @returns A TypeScript transformer factory. + */ +export function createWorkerTransformer( + fileProcessor: (file: string, importer: string) => string, +): ts.TransformerFactory { + return (context: ts.TransformationContext) => { + const nodeFactory = context.factory; + + const visitNode: ts.Visitor = (node: ts.Node) => { + // Check if the node is a valid new expression for a Worker or SharedWorker + // TODO: Add global scope check + if ( + !ts.isNewExpression(node) || + !ts.isIdentifier(node.expression) || + (node.expression.text !== 'Worker' && node.expression.text !== 'SharedWorker') + ) { + // Visit child nodes of non-Worker expressions + return ts.visitEachChild(node, visitNode, context); + } + + // Worker should have atleast one argument but not more than two + if (!node.arguments || node.arguments.length < 1 || node.arguments.length > 2) { + return node; + } + + // First argument must be a new URL expression + const workerUrlNode = node.arguments[0]; + // TODO: Add global scope check + if ( + !ts.isNewExpression(workerUrlNode) || + !ts.isIdentifier(workerUrlNode.expression) || + workerUrlNode.expression.text !== 'URL' + ) { + return node; + } + + // URL must have 2 arguments + if (!workerUrlNode.arguments || workerUrlNode.arguments.length !== 2) { + return node; + } + + // URL arguments must be a string and then `import.meta.url` + if ( + !ts.isStringLiteralLike(workerUrlNode.arguments[0]) || + !ts.isPropertyAccessExpression(workerUrlNode.arguments[1]) || + !ts.isMetaProperty(workerUrlNode.arguments[1].expression) || + workerUrlNode.arguments[1].name.text !== 'url' + ) { + return node; + } + + const filePath = workerUrlNode.arguments[0].text; + const importer = node.getSourceFile().fileName; + + // Process the file + const replacementPath = fileProcessor(filePath, importer); + + // Update if the path changed + if (replacementPath !== filePath) { + return nodeFactory.updateNewExpression( + node, + node.expression, + node.typeArguments, + // Update Worker arguments + ts.setTextRange( + nodeFactory.createNodeArray( + [ + nodeFactory.updateNewExpression( + workerUrlNode, + workerUrlNode.expression, + workerUrlNode.typeArguments, + // Update URL arguments + ts.setTextRange( + nodeFactory.createNodeArray( + [ + nodeFactory.createStringLiteral(replacementPath), + workerUrlNode.arguments[1], + ], + workerUrlNode.arguments.hasTrailingComma, + ), + workerUrlNode.arguments, + ), + ), + node.arguments[1], + ], + node.arguments.hasTrailingComma, + ), + node.arguments, + ), + ); + } else { + return node; + } + }; + + return (sourceFile) => { + // Skip transformer if there are no Workers + if (!sourceFile.text.includes('Worker')) { + return sourceFile; + } + + return ts.visitEachChild(sourceFile, visitNode, context); + }; + }; +}