diff --git a/packages/angular/build/src/tools/babel/transform.ts b/packages/angular/build/src/tools/babel/transform.ts new file mode 100644 index 000000000000..4b5ba7334608 --- /dev/null +++ b/packages/angular/build/src/tools/babel/transform.ts @@ -0,0 +1,173 @@ +/** + * @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.dev/license + */ + +import { type PluginItem, transformAsync } from '@babel/core'; +import fs from 'node:fs'; +import path from 'node:path'; +import { loadEsmModule } from '../../utils/load-esm'; + +export interface BabelTransformOptions { + sourcemap: boolean; + thirdPartySourcemaps: boolean; + advancedOptimizations: boolean; + skipLinker?: boolean; + sideEffects?: boolean; + jit: boolean; + instrumentForCoverage?: boolean; +} + +/** + * Cached instance of the compiler-cli linker's createEs2015LinkerPlugin function. + */ +let linkerPluginCreator: + | typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin + | undefined; + +/** + * Cached instance of the compiler-cli linker's needsLinking function. + */ +let needsLinking: typeof import('@angular/compiler-cli/linker').needsLinking | undefined; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +export async function transformWithBabel( + filename: string, + data: string | Uint8Array, + options: BabelTransformOptions, +): Promise { + const textData = typeof data === 'string' ? data : textDecoder.decode(data); + const shouldLink = !options.skipLinker && (await requiresLinking(filename, textData)); + const useInputSourcemap = + options.sourcemap && + (!!options.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); + + // @ts-expect-error Import attribute syntax plugin does not currently have type definitions + const { default: importAttributePlugin } = await import('@babel/plugin-syntax-import-attributes'); + const plugins: PluginItem[] = [importAttributePlugin]; + + if (options.instrumentForCoverage) { + const { default: coveragePlugin } = await import('./plugins/add-code-coverage.js'); + plugins.push(coveragePlugin); + } + + if (shouldLink) { + // Lazy load the linker plugin only when linking is required + const linkerPlugin = await createLinkerPlugin(options); + plugins.push(linkerPlugin); + } + + if (options.advancedOptimizations) { + const sideEffectFree = options.sideEffects === false; + const safeAngularPackage = + sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename); + + const { adjustStaticMembers, adjustTypeScriptEnums, elideAngularMetadata, markTopLevelPure } = + await import('./plugins'); + + if (safeAngularPackage) { + plugins.push(markTopLevelPure); + } + + plugins.push(elideAngularMetadata, adjustTypeScriptEnums, [ + adjustStaticMembers, + { wrapDecorators: sideEffectFree }, + ]); + } + + // If no additional transformations are needed, return the data directly + if (plugins.length === 0) { + // Strip sourcemaps if they should not be used + return textEncoder.encode( + useInputSourcemap ? textData : textData.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''), + ); + } + + const result = await transformAsync(textData, { + filename, + inputSourceMap: (useInputSourcemap ? undefined : false) as undefined, + sourceMaps: useInputSourcemap ? 'inline' : false, + compact: false, + configFile: false, + babelrc: false, + browserslistConfigFile: false, + plugins, + }); + + const outputCode = result?.code ?? textData; + + // Strip sourcemaps if they should not be used. + // Babel will keep the original comments even if sourcemaps are disabled. + return textEncoder.encode( + useInputSourcemap ? outputCode : outputCode.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''), + ); +} + +async function requiresLinking(path: string, source: string): Promise { + // @angular/core and @angular/compiler will cause false positives + // Also, TypeScript files do not require linking + if (/[\\/]@angular[\\/](?:compiler|core)|\.tsx?$/.test(path)) { + return false; + } + + if (!needsLinking) { + // Load ESM `@angular/compiler-cli/linker` using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + const linkerModule = await loadEsmModule( + '@angular/compiler-cli/linker', + ); + needsLinking = linkerModule.needsLinking; + } + + return needsLinking(path, source); +} + +async function createLinkerPlugin(options: BabelTransformOptions) { + linkerPluginCreator ??= ( + await loadEsmModule( + '@angular/compiler-cli/linker/babel', + ) + ).createEs2015LinkerPlugin; + + const linkerPlugin = linkerPluginCreator({ + linkerJitMode: options.jit, + // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed. + sourceMapping: false, + logger: { + level: 1, // Info level + debug(...args: string[]) { + // eslint-disable-next-line no-console + console.debug(args); + }, + info(...args: string[]) { + // eslint-disable-next-line no-console + console.info(args); + }, + warn(...args: string[]) { + // eslint-disable-next-line no-console + console.warn(args); + }, + error(...args: string[]) { + // eslint-disable-next-line no-console + console.error(args); + }, + }, + fileSystem: { + resolve: path.resolve, + exists: fs.existsSync, + dirname: path.dirname, + relative: path.relative, + readFile: fs.readFileSync, + // Node.JS types don't overlap the Compiler types. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }); + + return linkerPlugin; +} diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts index 7bf29fc2e7a8..868676f3c6ee 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts @@ -6,180 +6,21 @@ * found in the LICENSE file at https://angular.dev/license */ -import { type PluginItem, transformAsync } from '@babel/core'; -import fs from 'node:fs'; -import path from 'node:path'; import Piscina from 'piscina'; -import { loadEsmModule } from '../../utils/load-esm'; +import { BabelTransformOptions, transformWithBabel } from '../babel/transform'; -interface JavaScriptTransformRequest { +interface JavaScriptTransformRequest extends BabelTransformOptions { filename: string; data: string | Uint8Array; - sourcemap: boolean; - thirdPartySourcemaps: boolean; - advancedOptimizations: boolean; - skipLinker?: boolean; - sideEffects?: boolean; - jit: boolean; - instrumentForCoverage?: boolean; } -const textDecoder = new TextDecoder(); -const textEncoder = new TextEncoder(); - export default async function transformJavaScript( request: JavaScriptTransformRequest, ): Promise { const { filename, data, ...options } = request; - const textData = typeof data === 'string' ? data : textDecoder.decode(data); - const transformedData = await transformWithBabel(filename, textData, options); + const transformedData = await transformWithBabel(filename, data, options); // Transfer the data via `move` instead of cloning - return Piscina.move(textEncoder.encode(transformedData)); -} - -/** - * Cached instance of the compiler-cli linker's createEs2015LinkerPlugin function. - */ -let linkerPluginCreator: - | typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin - | undefined; - -/** - * Cached instance of the compiler-cli linker's needsLinking function. - */ -let needsLinking: typeof import('@angular/compiler-cli/linker').needsLinking | undefined; - -async function transformWithBabel( - filename: string, - data: string, - options: Omit, -): Promise { - const shouldLink = !options.skipLinker && (await requiresLinking(filename, data)); - const useInputSourcemap = - options.sourcemap && - (!!options.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); - - // @ts-expect-error Import attribute syntax plugin does not currently have type definitions - const { default: importAttributePlugin } = await import('@babel/plugin-syntax-import-attributes'); - const plugins: PluginItem[] = [importAttributePlugin]; - - if (options.instrumentForCoverage) { - const { default: coveragePlugin } = await import('../babel/plugins/add-code-coverage.js'); - plugins.push(coveragePlugin); - } - - if (shouldLink) { - // Lazy load the linker plugin only when linking is required - const linkerPlugin = await createLinkerPlugin(options); - plugins.push(linkerPlugin); - } - - if (options.advancedOptimizations) { - const sideEffectFree = options.sideEffects === false; - const safeAngularPackage = - sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename); - - const { adjustStaticMembers, adjustTypeScriptEnums, elideAngularMetadata, markTopLevelPure } = - await import('../babel/plugins'); - - if (safeAngularPackage) { - plugins.push(markTopLevelPure); - } - - plugins.push(elideAngularMetadata, adjustTypeScriptEnums, [ - adjustStaticMembers, - { wrapDecorators: sideEffectFree }, - ]); - } - - // If no additional transformations are needed, return the data directly - if (plugins.length === 0) { - // Strip sourcemaps if they should not be used - return useInputSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); - } - - const result = await transformAsync(data, { - filename, - inputSourceMap: (useInputSourcemap ? undefined : false) as undefined, - sourceMaps: useInputSourcemap ? 'inline' : false, - compact: false, - configFile: false, - babelrc: false, - browserslistConfigFile: false, - plugins, - }); - - const outputCode = result?.code ?? data; - - // Strip sourcemaps if they should not be used. - // Babel will keep the original comments even if sourcemaps are disabled. - return useInputSourcemap - ? outputCode - : outputCode.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); -} - -async function requiresLinking(path: string, source: string): Promise { - // @angular/core and @angular/compiler will cause false positives - // Also, TypeScript files do not require linking - if (/[\\/]@angular[\\/](?:compiler|core)|\.tsx?$/.test(path)) { - return false; - } - - if (!needsLinking) { - // Load ESM `@angular/compiler-cli/linker` using the TypeScript dynamic import workaround. - // Once TypeScript provides support for keeping the dynamic import this workaround can be - // changed to a direct dynamic import. - const linkerModule = await loadEsmModule( - '@angular/compiler-cli/linker', - ); - needsLinking = linkerModule.needsLinking; - } - - return needsLinking(path, source); -} - -async function createLinkerPlugin(options: Omit) { - linkerPluginCreator ??= ( - await loadEsmModule( - '@angular/compiler-cli/linker/babel', - ) - ).createEs2015LinkerPlugin; - - const linkerPlugin = linkerPluginCreator({ - linkerJitMode: options.jit, - // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed. - sourceMapping: false, - logger: { - level: 1, // Info level - debug(...args: string[]) { - // eslint-disable-next-line no-console - console.debug(args); - }, - info(...args: string[]) { - // eslint-disable-next-line no-console - console.info(args); - }, - warn(...args: string[]) { - // eslint-disable-next-line no-console - console.warn(args); - }, - error(...args: string[]) { - // eslint-disable-next-line no-console - console.error(args); - }, - }, - fileSystem: { - resolve: path.resolve, - exists: fs.existsSync, - dirname: path.dirname, - relative: path.relative, - readFile: fs.readFileSync, - // Node.JS types don't overlap the Compiler types. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - }); - - return linkerPlugin; + return Piscina.move(transformedData); } diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index 202d922f40ea..2e323bd26f6e 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -8,7 +8,6 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; -import { IMPORT_EXEC_ARGV } from '../../utils/server-rendering/esm-in-memory-loader/utils'; import { WorkerPool } from '../../utils/worker-pool'; import { Cache } from './cache'; @@ -59,8 +58,6 @@ export class JavaScriptTransformer { this.#workerPool ??= new WorkerPool({ filename: require.resolve('./javascript-transformer-worker'), maxThreads: this.maxThreads, - // Prevent passing `--import` (loader-hooks) from parent to child worker. - execArgv: process.execArgv.filter((v) => v !== IMPORT_EXEC_ARGV), }); return this.#workerPool; diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts index 74a2df7636ec..1c70989a5053 100644 --- a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts @@ -8,10 +8,11 @@ import assert from 'node:assert'; import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'url'; -import { JavaScriptTransformer } from '../../../tools/esbuild/javascript-transformer'; +import { transformWithBabel } from '../../../tools/babel/transform'; /** * @note For some unknown reason, setting `globalThis.ngServerMode = true` does not work when using ESM loader hooks. @@ -33,14 +34,6 @@ export interface ESMInMemoryFileLoaderWorkerData { let memoryVirtualRootUrl: string; let outputFiles: Record; -const javascriptTransformer = new JavaScriptTransformer( - // Always enable JIT linking to support applications built with and without AOT. - // In a development environment the additional scope information does not - // have a negative effect unlike production where final output size is relevant. - { sourcemap: true, jit: true }, - 1, -); - export function initialize(data: ESMInMemoryFileLoaderWorkerData) { // This path does not actually exist but is used to overlay the in memory files with the // actual filesystem for resolution purposes. @@ -138,13 +131,22 @@ export async function load(url: string, context: { format?: string | null }, nex // need linking are ESM only. if (format === 'module' && isFileProtocol(url)) { const filePath = fileURLToPath(url); - let source = await javascriptTransformer.transformFile(filePath); + let content = await readFile(filePath); if (filePath.includes('@angular/')) { // Prepend 'var ngServerMode=true;' to the source. - source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, source]); + content = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, content]); } + const source = await transformWithBabel( + filePath, + content, + // Always enable JIT linking to support applications built with and without AOT. + // In a development environment the additional scope information does not + // have a negative effect unlike production where final output size is relevant. + { sourcemap: true, jit: true, advancedOptimizations: false, thirdPartySourcemaps: false }, + ); + return { format, shortCircuit: true, @@ -159,11 +161,3 @@ export async function load(url: string, context: { format?: string | null }, nex function isFileProtocol(url: string): boolean { return url.startsWith('file://'); } - -function handleProcessExit(): void { - void javascriptTransformer.close(); -} - -process.once('exit', handleProcessExit); -process.once('SIGINT', handleProcessExit); -process.once('uncaughtException', handleProcessExit);