diff --git a/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts new file mode 100644 index 000000000000..5ae62f020c1c --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts @@ -0,0 +1,275 @@ +/** + * @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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Compiled and base64 encoded WASM file for the following WAT: + * ``` + * (module + * (export "multiply" (func $multiply)) + * (func $multiply (param i32 i32) (result i32) + * local.get 0 + * local.get 1 + * i32.mul + * ) + * ) + * ``` + */ +const exportWasmBase64 = + 'AGFzbQEAAAABBwFgAn9/AX8DAgEABwwBCG11bHRpcGx5AAAKCQEHACAAIAFsCwAXBG5hbWUBCwEACG11bHRpcGx5AgMBAAA='; +const exportWasmBytes = Buffer.from(exportWasmBase64, 'base64'); + +/** + * Compiled and base64 encoded WASM file for the following WAT: + * ``` + * (module + * (import "./values" "getValue" (func $getvalue (result i32))) + * (export "multiply" (func $multiply)) + * (export "subtract1" (func $subtract)) + * (func $multiply (param i32 i32) (result i32) + * local.get 0 + * local.get 1 + * i32.mul + * ) + * (func $subtract (param i32) (result i32) + * call $getvalue + * local.get 0 + * i32.sub + * ) + * ) + * ``` + */ +const importWasmBase64 = + 'AGFzbQEAAAABEANgAAF/YAJ/fwF/YAF/AX8CFQEILi92YWx1ZXMIZ2V0VmFsdWUAAAMDAgECBxgCCG11bHRpcGx5AAEJc3VidHJhY3QxAAIKEQIHACAAIAFsCwcAEAAgAGsLAC8EbmFtZQEfAwAIZ2V0dmFsdWUBCG11bHRpcGx5AghzdWJ0cmFjdAIHAwAAAQACAA=='; +const importWasmBytes = Buffer.from(importWasmBase64, 'base64'); + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Supports WASM/ES module integration"', () => { + it('should inject initialization code and add an export', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('multiply'); + }); + + it('should compile successfully with a provided type definition file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + await harness.writeFile( + 'src/multiply.wasm.d.ts', + 'export declare function multiply(a: number, b: number): number;', + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('multiply'); + }); + + it('should add WASM defined imports and include resolved TS file for import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/subtract.wasm', importWasmBytes); + + // Create TS file that is expect by WASM file + await harness.writeFile( + 'src/values.ts', + ` + export function getValue(): number { return 100; } + `, + ); + // The file is not imported into any actual TS files so it needs to be manually added to the TypeScript program + await harness.modifyFile('src/tsconfig.app.json', (content) => + content.replace('"main.ts",', '"main.ts","values.ts",'), + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { subtract1 } from './subtract.wasm'; + + console.log(subtract1(5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('subtract1'); + harness.expectFile('dist/browser/main.js').content.toContain('./values'); + harness.expectFile('dist/browser/main.js').content.toContain('getValue'); + }); + + it('should add WASM defined imports and include resolved JS file for import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/subtract.wasm', importWasmBytes); + + // Create JS file that is expect by WASM file + await harness.writeFile( + 'src/values.js', + ` + export function getValue() { return 100; } + `, + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { subtract1 } from './subtract.wasm'; + + console.log(subtract1(5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('subtract1'); + harness.expectFile('dist/browser/main.js').content.toContain('./values'); + harness.expectFile('dist/browser/main.js').content.toContain('getValue'); + }); + + it('should inline WASM files less than 10kb', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure WASM is present in output code + harness.expectFile('dist/browser/main.js').content.toContain(exportWasmBase64); + }); + + it('should show an error on invalid WASM file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', 'NOT_WASM'); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Unable to analyze WASM file'), + }), + ); + }); + + it('should show an error if using Zone.js', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['zone.js'], + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', importWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + 'WASM/ES module integration imports are not supported with Zone.js applications', + ), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts index ed1df518c8b9..b09a96d91361 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -23,6 +23,7 @@ import { createRxjsEsmResolutionPlugin } from './rxjs-esm-resolution-plugin'; import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin'; import { getFeatureSupport, isZonelessApp } from './utils'; import { createVirtualModulePlugin } from './virtual-module-plugin'; +import { createWasmPlugin } from './wasm-plugin'; export function createBrowserCodeBundleOptions( options: NormalizedApplicationBuildOptions, @@ -37,6 +38,8 @@ export function createBrowserCodeBundleOptions( sourceFileCache, ); + const zoneless = isZonelessApp(polyfills); + const buildOptions: BuildOptions = { ...getEsBuildCommonOptions(options), platform: 'browser', @@ -48,8 +51,9 @@ export function createBrowserCodeBundleOptions( entryNames: outputNames.bundles, entryPoints, target, - supported: getFeatureSupport(target, isZonelessApp(polyfills)), + supported: getFeatureSupport(target, zoneless), plugins: [ + createWasmPlugin({ allowAsync: zoneless, cache: sourceFileCache?.loadResultCache }), createSourcemapIgnorelistPlugin(), createCompilerPlugin( // JS/TS options @@ -186,6 +190,8 @@ export function createServerCodeBundleOptions( entryPoints['server'] = ssrEntryPoint; } + const zoneless = isZonelessApp(polyfills); + const buildOptions: BuildOptions = { ...getEsBuildCommonOptions(options), platform: 'node', @@ -202,8 +208,9 @@ export function createServerCodeBundleOptions( js: `import './polyfills.server.mjs';`, }, entryPoints, - supported: getFeatureSupport(target, isZonelessApp(polyfills)), + supported: getFeatureSupport(target, zoneless), plugins: [ + createWasmPlugin({ allowAsync: zoneless, cache: sourceFileCache?.loadResultCache }), createSourcemapIgnorelistPlugin(), createCompilerPlugin( // JS/TS options diff --git a/packages/angular/build/src/tools/esbuild/wasm-plugin.ts b/packages/angular/build/src/tools/esbuild/wasm-plugin.ts new file mode 100644 index 000000000000..8ad6fc262522 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/wasm-plugin.ts @@ -0,0 +1,255 @@ +/** + * @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 { Plugin, ResolveOptions } from 'esbuild'; +import assert from 'node:assert'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { assertIsError } from '../../utils/error'; +import { LoadResultCache, createCachedLoad } from './load-result-cache'; + +/** + * Options for the Angular WASM esbuild plugin + * @see createWasmPlugin + */ +export interface WasmPluginOptions { + /** Allow generation of async (proposal compliant) WASM imports. This requires zoneless to enable async/await. */ + allowAsync?: boolean; + /** Load results cache. */ + cache?: LoadResultCache; +} + +const WASM_INIT_NAMESPACE = 'angular:wasm:init'; +const WASM_CONTENTS_NAMESPACE = 'angular:wasm:contents'; +const WASM_RESOLVE_SYMBOL = Symbol('WASM_RESOLVE_SYMBOL'); + +// See: https://github.com/tc39/proposal-regexp-unicode-property-escapes/blob/fe6d07fad74cd0192d154966baa1e95e7cda78a1/README.md#other-examples +const ecmaIdentifierNameRegExp = /^(?:[$_\p{ID_Start}])(?:[$_\u200C\u200D\p{ID_Continue}])*$/u; + +/** + * Creates an esbuild plugin to use WASM files with import statements and expressions. + * The default behavior follows the WebAssembly/ES mode integration proposal found at + * https://github.com/WebAssembly/esm-integration/tree/main/proposals/esm-integration. + * This behavior requires top-level await support which is only available in zoneless + * Angular applications. + * @returns An esbuild plugin. + */ +export function createWasmPlugin(options: WasmPluginOptions): Plugin { + const { allowAsync = false, cache } = options; + + return { + name: 'angular-wasm', + setup(build): void { + build.onResolve({ filter: /.wasm$/ }, async (args) => { + // Skip if already resolving the WASM file to avoid infinite resolution + if (args.pluginData?.[WASM_RESOLVE_SYMBOL]) { + return; + } + // Skip if not an import statement or expression + if (args.kind !== 'import-statement' && args.kind !== 'dynamic-import') { + return; + } + + // When in the initialization namespace, the content has already been resolved + // and only needs to be loaded for use with the initialization code. + if (args.namespace === WASM_INIT_NAMESPACE) { + return { + namespace: WASM_CONTENTS_NAMESPACE, + path: join(args.resolveDir, args.path), + pluginData: args.pluginData, + }; + } + + // Skip if a custom loader is defined + if (build.initialOptions.loader?.['.wasm'] || args.with['loader']) { + return; + } + + // Attempt full resolution of the WASM file + const resolveOptions: ResolveOptions & { path?: string } = { + ...args, + pluginData: { [WASM_RESOLVE_SYMBOL]: true }, + }; + // The "path" property will cause an error if used in the resolve call + delete resolveOptions.path; + + const result = await build.resolve(args.path, resolveOptions); + + // Skip if there are errors, is external, or another plugin resolves to a custom namespace + if (result.errors.length > 0 || result.external || result.namespace !== 'file') { + // Reuse already resolved result + return result; + } + + return { + ...result, + namespace: WASM_INIT_NAMESPACE, + }; + }); + + build.onLoad( + { filter: /.wasm$/, namespace: WASM_INIT_NAMESPACE }, + createCachedLoad(cache, async (args) => { + // Ensure async mode is supported + if (!allowAsync) { + return { + errors: [ + { + text: 'WASM/ES module integration imports are not supported with Zone.js applications', + notes: [ + { + text: 'Information about zoneless Angular applications can be found here: https://angular.dev/guide/experimental/zoneless', + }, + ], + }, + ], + }; + } + + const wasmContents = await readFile(args.path); + // Inline WASM code less than 10kB + const inlineWasm = wasmContents.byteLength < 10_000; + + // Add import of WASM contents + let initContents = `import ${inlineWasm ? 'wasmData' : 'wasmPath'} from ${JSON.stringify(basename(args.path))}`; + initContents += inlineWasm ? ' with { loader: "binary" };' : ';\n\n'; + + // Read from the file system when on Node.js (SSR) and not inline + if (!inlineWasm && build.initialOptions.platform === 'node') { + initContents += 'import { readFile } from "node:fs/promises";\n'; + initContents += 'const wasmData = await readFile(wasmPath);\n'; + } + + // Create initialization function + initContents += generateInitHelper( + !inlineWasm && build.initialOptions.platform !== 'node', + wasmContents, + ); + + // Analyze WASM for imports and exports + let importModuleNames, exportNames; + try { + const wasm = await WebAssembly.compile(wasmContents); + importModuleNames = new Set( + WebAssembly.Module.imports(wasm).map((value) => value.module), + ); + exportNames = WebAssembly.Module.exports(wasm).map((value) => value.name); + } catch (error) { + assertIsError(error); + + return { + errors: [{ text: 'Unable to analyze WASM file', notes: [{ text: error.message }] }], + }; + } + + // Ensure export names are valid JavaScript identifiers + const invalidExportNames = exportNames.filter( + (name) => !ecmaIdentifierNameRegExp.test(name), + ); + if (invalidExportNames.length > 0) { + return { + errors: invalidExportNames.map((name) => ({ + text: 'WASM export names must be valid JavaScript identifiers', + notes: [ + { + text: `The export "${name}" is not valid. The WASM file should be updated to remove this error.`, + }, + ], + })), + }; + } + + // Add import statements and setup import object + initContents += 'const importObject = Object.create(null);\n'; + let importIndex = 0; + for (const moduleName of importModuleNames) { + // Add a namespace import for each module name + initContents += `import * as wasm_import_${++importIndex} from ${JSON.stringify(moduleName)};\n`; + // Add the namespace object to the import object + initContents += `importObject[${JSON.stringify(moduleName)}] = wasm_import_${importIndex};\n`; + } + + // Instantiate the module + initContents += 'const instance = await init(importObject);\n'; + + // Add exports + const exportNameList = exportNames.join(', '); + initContents += `const { ${exportNameList} } = instance.exports;\n`; + initContents += `export { ${exportNameList} }\n`; + + return { + contents: initContents, + loader: 'js', + resolveDir: dirname(args.path), + pluginData: { wasmContents }, + watchFiles: [args.path], + }; + }), + ); + + build.onLoad({ filter: /.wasm$/, namespace: WASM_CONTENTS_NAMESPACE }, async (args) => { + const contents = args.pluginData.wasmContents ?? (await readFile(args.path)); + + let loader: 'binary' | 'file' = 'file'; + if (args.with.loader) { + assert( + args.with.loader === 'binary' || args.with.loader === 'file', + 'WASM loader type should only be binary or file.', + ); + loader = args.with.loader; + } + + return { + contents, + loader, + watchFiles: [args.path], + }; + }); + }, + }; +} + +/** + * Generates the string content of the WASM initialization helper function. + * This function supports both file fetching and inline byte data depending on + * the preferred option for the WASM file. When fetching, an integrity hash is + * also generated and used with the fetch action. + * + * @param streaming Uses fetch and WebAssembly.instantiateStreaming. + * @param wasmContents The binary contents to generate an integrity hash. + * @returns A string containing the initialization function. + */ +function generateInitHelper(streaming: boolean, wasmContents: Uint8Array) { + let resultContents; + if (streaming) { + const fetchOptions = { + integrity: 'sha256-' + createHash('sha-256').update(wasmContents).digest('base64'), + }; + const fetchContents = `fetch(new URL(wasmPath, import.meta.url), ${JSON.stringify(fetchOptions)})`; + resultContents = `await WebAssembly.instantiateStreaming(${fetchContents}, imports)`; + } else { + resultContents = 'await WebAssembly.instantiate(wasmData, imports)'; + } + + const contents = ` +let mod; +async function init(imports) { + if (mod) { + return await WebAssembly.instantiate(mod, imports); + } + + const result = ${resultContents}; + mod = result.module; + + return result.instance; +} +`; + + return contents; +} diff --git a/packages/angular/build/src/tools/esbuild/wasm.d.ts b/packages/angular/build/src/tools/esbuild/wasm.d.ts new file mode 100644 index 000000000000..e3b45032eccc --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/wasm.d.ts @@ -0,0 +1,25 @@ +/** + * @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 + */ + +/** @fileoverview + * TypeScript does not provide a separate lib for WASM types and the Node.js + * types (`@types/node`) does not contain them either. This type definition + * file provides type information for the subset of functionality required + * by the Angular build process. Ideally this can be removed when the WASM + * type situation has improved. + */ + +declare namespace WebAssembly { + class Module { + constructor(data: Uint8Array); + + static imports(mod: Module): { module: string; name: string }[]; + static exports(mode: Module): { name: string }[]; + } + function compile(data: Uint8Array): Promise; +} diff --git a/tests/legacy-cli/e2e.bzl b/tests/legacy-cli/e2e.bzl index 97f3e149beed..139433f97858 100644 --- a/tests/legacy-cli/e2e.bzl +++ b/tests/legacy-cli/e2e.bzl @@ -45,6 +45,7 @@ WEBPACK_IGNORE_TESTS = [ "tests/commands/serve/ssr-http-requests-assets.js", "tests/build/prerender/http-requests-assets.js", "tests/build/prerender/error-with-sourcemaps.js", + "tests/build/wasm-esm.js", ] def _to_glob(patterns): diff --git a/tests/legacy-cli/e2e/tests/build/wasm-esm.ts b/tests/legacy-cli/e2e/tests/build/wasm-esm.ts new file mode 100644 index 000000000000..f605930e80f2 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/wasm-esm.ts @@ -0,0 +1,97 @@ +/** + * @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 { writeFile } from 'node:fs/promises'; +import { ng } from '../../utils/process'; +import { prependToFile, replaceInFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; + +/** + * Compiled and base64 encoded WASM file for the following WAT: + * ``` + * (module + * (import "./values" "getValue" (func $getvalue (result i32))) + * (export "multiply" (func $multiply)) + * (export "subtract1" (func $subtract)) + * (func $multiply (param i32 i32) (result i32) + * local.get 0 + * local.get 1 + * i32.mul + * ) + * (func $subtract (param i32) (result i32) + * call $getvalue + * local.get 0 + * i32.sub + * ) + * ) + * ``` + */ +const importWasmBase64 = + 'AGFzbQEAAAABEANgAAF/YAJ/fwF/YAF/AX8CFQEILi92YWx1ZXMIZ2V0VmFsdWUAAAMDAgECBxgCCG11bHRpcGx5AAEJc3VidHJhY3QxAAIKEQIHACAAIAFsCwcAEAAgAGsLAC8EbmFtZQEfAwAIZ2V0dmFsdWUBCG11bHRpcGx5AghzdWJ0cmFjdAIHAwAAAQACAA=='; +const importWasmBytes = Buffer.from(importWasmBase64, 'base64'); + +export default async function () { + // Add WASM file to project + await writeFile('src/app/multiply.wasm', importWasmBytes); + await writeFile( + 'src/app/multiply.wasm.d.ts', + 'export declare function multiply(a: number, b: number): number; export declare function subtract1(a: number): number;', + ); + + // Add requested WASM import file + await writeFile('src/app/values.js', 'export function getValue() { return 100; }'); + + // Use WASM file in project + await prependToFile( + 'src/app/app.component.ts', + ` + import { multiply, subtract1 } from './multiply.wasm'; + `, + ); + await replaceInFile( + 'src/app/app.component.ts', + "'test-project'", + 'multiply(4, 5) + subtract1(88)', + ); + + // Remove Zone.js from polyfills and make zoneless + await updateJsonFile('angular.json', (json) => { + // Remove bundle budgets to avoid a build error due to the expected increased output size + // of a JIT production build. + json.projects['test-project'].architect.build.options.polyfills = []; + }); + await replaceInFile( + 'src/app/app.config.ts', + 'provideZoneChangeDetection', + 'provideExperimentalZonelessChangeDetection', + ); + await replaceInFile( + 'src/app/app.config.ts', + 'provideZoneChangeDetection({ eventCoalescing: true })', + 'provideExperimentalZonelessChangeDetection()', + ); + + await ng('build'); + + // Update E2E test to check for WASM execution + await writeFile( + 'e2e/src/app.e2e-spec.ts', + ` + import { AppPage } from './app.po'; + import { browser, logging } from 'protractor'; + describe('WASM execution', () => { + it('should log WASM result messages', async () => { + const page = new AppPage(); + await page.navigateTo(); + expect(await page.getTitleText()).toEqual('Hello, 32'); + }); + }); + `, + ); + + await ng('e2e'); +}