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); + }; + }; +}