Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/angular/build/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ ts_project(
":node_modules/@angular/ssr",
":node_modules/@babel/core",
":node_modules/@inquirer/confirm",
":node_modules/@oxc-parser/binding-wasm32-wasi",
":node_modules/@oxc-project/types",
":node_modules/@parcel/watcher",
":node_modules/@vitejs/plugin-basic-ssl",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { FatalLinkerError } from '@angular/compiler-cli/linker';
import { parseSync } from 'oxc-parser';
import { loadOxcParser } from '../../oxc/load-oxc-parser';
import { OxcAstHost } from './oxc-ast-host';

describe('OxcAstHost', () => {
Expand All @@ -18,14 +18,14 @@ describe('OxcAstHost', () => {
});

function parseExpression(code: string): unknown {
const { program } = parseSync('test.js', `const x = ${code};`, { range: true });
const { program } = loadOxcParser().parseSync('test.js', `const x = ${code};`, { range: true });
const stmt = program.body[0] as { declarations: Array<{ init: unknown }> };

return stmt.declarations[0].init;
}

function parseStatement(code: string): unknown {
const { program } = parseSync('test.js', code, { range: true });
const { program } = loadOxcParser().parseSync('test.js', code, { range: true });

return program.body[0];
}
Expand Down
5 changes: 2 additions & 3 deletions packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/

import '../oxc/oxc-wasi-shim';
import { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping';
import {
type ɵParsedTranslation,
Expand All @@ -17,7 +16,7 @@ import {
} from '@angular/localize';
import { MagicString } from 'magic-string';
import { deserialize } from 'node:v8';
import { parseSync } from 'oxc-parser';
import { loadOxcParser } from '../oxc/load-oxc-parser';
import { traversePostOrder } from '../oxc/traversal';
import { createSharedTranslationProxy } from './i18n-translation-reader';
import { loadLocaleData } from './locale-data';
Expand Down Expand Up @@ -403,7 +402,7 @@ interface FileLocalizeMetadata {
* @returns The extracted localization metadata.
*/
function extractLocalizeMetadata(filename: string, code: string): FileLocalizeMetadata {
const { program } = parseSync(filename, code, {
const { program } = loadOxcParser().parseSync(filename, code, {
sourceType: 'unambiguous',
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,11 @@ export default async function transformJavaScript(
textData = textDecoder.decode(trailing.code);
isAlreadyStripped = true;
} else {
// Fallback to full decode and state-machine stripping
// Not a valid trailing sourcemap: fall back to full decode and oxc comment stripping
textData = textDecoder.decode(data);
}
} else {
// Multiple comments or comment not at line start: fall back to full decode and string parser
// Multiple comments or comment not at line start: fall back to full decode and oxc comment stripping
textData = textDecoder.decode(data);
}
} else {
Expand Down
124 changes: 124 additions & 0 deletions packages/angular/build/src/tools/oxc/load-oxc-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* @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 { Module, createRequire } from 'node:module';

/**
* Synchronous subset of the `oxc-parser` module.
*
* Async `parse()` is intentionally excluded because it spawns internal `wasi-worker.mjs`
* threads that do not execute the WASI compatibility shim and will fail to initialize.
*/
export type OxcParser = Omit<typeof import('oxc-parser'), 'parse'>;

let oxcParser: OxcParser | undefined;

/**
* Lazily loads and caches `oxc-parser`, applying a scoped WebAssembly (WASI) compatibility
* shim when running in WebContainer environments or when NAPI-RS WASI mode is forced.
*/
export function loadOxcParser(): OxcParser {
return (oxcParser ??= initializeOxcParser());
}

/**
* Workaround for an upstream incompatibility in `@oxc-parser/binding-wasm32-wasi` when
* falling back to WebAssembly (e.g., inside WebContainers or StackBlitz):
*
* `@oxc-parser/binding-wasm32-wasi` declares a dependency on `@emnapi/core@2.0.0-alpha.6`,
* but depends on `@napi-rs/wasm-runtime@~1.2.6` which expects `@emnapi/core@^1.7.1 || 2.0.0-beta.2`.
* 1. In `@napi-rs/wasm-runtime@1.2.6`, the runtime attempts to assign `initWorker` to
* `emnapi_shared.addonModule`, which is undefined in `alpha.6` where the property was named
* `napiModule`. Aliasing `addonModule` to `this.napiModule` on `Object.prototype` allows the
* WASI runtime to initialize successfully.
* 2. Under npm, `@napi-rs/wasm-runtime@1.2.6`'s peerDependencies cause npm to hoist `@emnapi/core@1.x`
* to top-level `node_modules` while nesting `2.0.0-alpha.6` under `@oxc-parser/binding-wasm32-wasi`,
* causing an ABI mismatch (`this.bridge.setLastError is not a function`). Under pnpm, `oxc-parser`
* does not declare `@oxc-parser/binding-wasm32-wasi` in its own dependencies. Resolving these
* packages relative to `@angular/build` and `@oxc-parser/binding-wasm32-wasi` ensures consistent
* module resolution across package managers.
*
* Because `oxc-parser` evaluates its WASI bindings synchronously during `require('oxc-parser')`,
* `Module._resolveFilename` is restored immediately after loading so other main-thread WASI
* consumers (such as `rolldown` and `vite`) continue resolving their own `@emnapi/*` versions.
*
* See: https://github.com/oxc-project/oxc/issues/28717 and https://github.com/napi-rs/napi-rs/issues/3674
*/
function initializeOxcParser(): OxcParser {
const localRequire = createRequire(__filename);
const forceWasi = globalThis.process?.env?.['NAPI_RS_FORCE_WASI'];
const shouldInstallWasiShim = Boolean(
globalThis.process?.versions?.['webcontainer'] ||
forceWasi === 'true' ||
forceWasi === 'error' ||
globalThis.process?.env?.['NAPI_RS_WASI_FLAVOR'],
);

if (shouldInstallWasiShim) {
if (!('addonModule' in Object.prototype)) {
Object.defineProperty(Object.prototype, 'addonModule', {
get() {
return (this as { napiModule?: unknown } | null | undefined)?.napiModule;
},
set(value) {
if (
this &&
this !== Object.prototype &&
(typeof this === 'object' || typeof this === 'function')
) {
Object.defineProperty(this, 'addonModule', {
value,
writable: true,
configurable: true,
enumerable: true,
});
}
},
configurable: true,
enumerable: false,
});
}

const resolvedModules = new Map<string, string>();
try {
const wasiBindingPath = localRequire.resolve('@oxc-parser/binding-wasm32-wasi');
resolvedModules.set('@oxc-parser/binding-wasm32-wasi', wasiBindingPath);
const wasiRequire = createRequire(wasiBindingPath);
resolvedModules.set('@emnapi/core', wasiRequire.resolve('@emnapi/core'));
resolvedModules.set('@emnapi/runtime', wasiRequire.resolve('@emnapi/runtime'));
} catch {}

if (resolvedModules.size > 0) {
const moduleWithResolve = Module as typeof Module & {
_resolveFilename: (
request: string,
parent: unknown,
isMain: boolean,
options: unknown,
) => string;
};
const origResolveFilename = moduleWithResolve._resolveFilename;
moduleWithResolve._resolveFilename = function (request, parent, isMain, options) {
const resolved = resolvedModules.get(request);
if (resolved !== undefined) {
return resolved;
}

return origResolveFilename.call(this, request, parent, isMain, options);
};

try {
return localRequire('oxc-parser') as OxcParser;
} finally {
moduleWithResolve._resolveFilename = origResolveFilename;
}
}
}

return localRequire('oxc-parser') as OxcParser;
}
114 changes: 114 additions & 0 deletions packages/angular/build/src/tools/oxc/load-oxc-parser_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* @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 { Worker } from 'node:worker_threads';
import { loadOxcParser } from './load-oxc-parser';

describe('loadOxcParser', () => {
it('should cache and return the same oxc-parser module instance across calls', () => {
const first = loadOxcParser();
const second = loadOxcParser();

expect(first === second).toBeTrue();
expect(typeof first.parseSync).toBe('function');
expect(first.visitorKeys['Program']).toBeDefined();
});

it('should load the WASI binding under NAPI_RS_FORCE_WASI=error and restore Module._resolveFilename', async () => {
const loadOxcParserPath = require.resolve('./load-oxc-parser');
const sourceMapPath = require.resolve('../../utils/source-map');

const result = await new Promise<{
resolveFilenameRestored: boolean;
wasiBindingLoaded: boolean;
statementCount: number;
commentCount: number;
strippedCode: string;
getterValue: unknown;
ownAssignedValue: unknown;
hasOwnAfterAssign: boolean;
protoGetterPreserved: boolean;
emptyObjectValue: unknown;
}>((resolve, reject) => {
const workerScript = `
const { Module } = require('node:module');
const { parentPort, workerData } = require('node:worker_threads');
const { loadOxcParser } = require(workerData.loadOxcParserPath);
const { removeSourceMappingURL } = require(workerData.sourceMapPath);

const origResolveFilename = Module._resolveFilename;
const oxc = loadOxcParser();
const resolveFilenameRestored = Module._resolveFilename === origResolveFilename;
const wasiBindingLoaded = Object.keys(require.cache).some((key) =>
key.includes('binding-wasm32-wasi'),
);

const parsed = oxc.parseSync('test.js', 'const answer = 42;\\n//# sourceMappingURL=test.map');
const strippedCode = removeSourceMappingURL(
'//# sourceMappingURL=first.js.map\\nconst x = 1;\\n//# sourceMappingURL=second.js.map',
);

const receiver = { napiModule: 'from-napi-module' };
const getterValue = receiver.addonModule;
receiver.addonModule = 'own-value';
const hasOwnAfterAssign = Object.hasOwn(receiver, 'addonModule');
const ownAssignedValue = receiver.addonModule;

Object.prototype.addonModule = 'polluted';
const protoGetterPreserved =
typeof Object.getOwnPropertyDescriptor(Object.prototype, 'addonModule')?.get === 'function';
const emptyObjectValue = {}.addonModule;
(42).addonModule = 'primitive';

parentPort.postMessage({
resolveFilenameRestored,
wasiBindingLoaded,
statementCount: parsed.program.body.length,
commentCount: parsed.comments.length,
strippedCode,
getterValue,
ownAssignedValue,
hasOwnAfterAssign,
protoGetterPreserved,
emptyObjectValue,
});
`;

const worker = new Worker(workerScript, {
eval: true,
env: {
...process.env,
NAPI_RS_FORCE_WASI: 'error',
},
workerData: {
loadOxcParserPath,
sourceMapPath,
},
});

worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker exited with non-zero exit code ${code}`));
}
});
});

expect(result.resolveFilenameRestored).toBeTrue();
expect(result.wasiBindingLoaded).toBeTrue();
expect(result.statementCount).toBe(1);
expect(result.commentCount).toBe(1);
expect(result.strippedCode).toBe('\nconst x = 1;\n');
expect(result.getterValue).toBe('from-napi-module');
expect(result.hasOwnAfterAssign).toBeTrue();
expect(result.ownAssignedValue).toBe('own-value');
expect(result.protoGetterPreserved).toBeTrue();
expect(result.emptyObjectValue).toBeUndefined();
});
});
5 changes: 2 additions & 3 deletions packages/angular/build/src/tools/oxc/oxc-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@
* found in the LICENSE file at https://angular.dev/license
*/

import './oxc-wasi-shim';
import type { DecodedSourceMap } from '@ampproject/remapping';
import { needsLinking } from '@angular/compiler-cli/linker';
import type { BindingIdentifier, Class, Node } from '@oxc-project/types';
import { MagicString } from 'magic-string';
import { parseSync } from 'oxc-parser';
import { OxcLinker } from '../angular/linker/oxc-linker';
import { loadOxcParser } from './load-oxc-parser';
import { traversePostOrder } from './traversal';

export interface OxcTransformOptions {
Expand Down Expand Up @@ -262,7 +261,7 @@ function analyzeClassStaticProperties(classNode: Node, code: string): boolean {
*/
// eslint-disable-next-line max-lines-per-function
export function transform(filename: string, code: string, options: OxcTransformOptions) {
const { program } = parseSync(filename, code, { range: true });
const { program } = loadOxcParser().parseSync(filename, code, { range: true });
const source = new MagicString(code);

const shouldLink = options.link && needsLinking(filename, code);
Expand Down
Loading
Loading