From 575c988150f1e2ffae26532fc71ea21aee2fe45f Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:38:55 -0400 Subject: [PATCH 1/3] fix(@angular/build): use oxc-parser fallback when stripping source-map comments Previously, `removeSourceMappingURL` relied on a custom character-by-character state machine to strip top-level `//# sourceMappingURL=` comments from JavaScript strings. Because that scanner did not track regular expression literals, quote characters or backticks inside a regex literal (such as `/"/`) inverted string boundaries for the remainder of the file. When a subsequent string literal contained `//# sourceMappingURL=`, the scanner misidentified it as a top-level comment and truncated valid JavaScript, or failed to strip the actual trailing comment at the end of the chunk. Extend the single-trailing-comment fast path to string inputs and pass raw byte buffers directly from the Vite memory plugin so standard chunks slice trailing source-map comments without invoking a parser. When a file contains multiple or non-trailing `//# sourceMappingURL=` occurrences, lazily load `oxc-parser` and read only the `comments` of the parse result, avoiding JS-side AST deserialization while delegating full ECMAScript lexical handling (including regex literals, templates, and escapes) to Oxc. This keeps main-thread startup free of eager native or WASM parser loading. The fallback removes whitespace consistently with the fast path, removing comment-only lines entirely while preserving line terminators for comments that follow code. Also harden trailing URL and base64 validation so block comments or template literals closing on the same line are never misclassified by the fast path. Closes #34590 --- .../javascript-transformer-worker.ts | 4 +- .../vite/plugins/angular-memory-plugin.ts | 17 +- .../angular/build/src/utils/source-map.ts | 255 ++++++++---------- .../build/src/utils/source-map_spec.ts | 140 +++++++++- 4 files changed, 254 insertions(+), 162 deletions(-) diff --git a/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts index 87df19ee81eb..040eedaa4179 100644 --- a/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts @@ -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 { diff --git a/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts index c6dc243385a8..c30365bbf142 100644 --- a/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts +++ b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts @@ -99,19 +99,30 @@ export async function createAngularMemoryPlugin( return undefined; } - const code = Buffer.from(codeContents).toString('utf-8'); const mapContents = outputFiles.get(relativeFile + '.map')?.contents; + const strippedContents = mapContents ? removeSourceMappingURL(codeContents) : codeContents; return { // Remove source map URL comments from the code if a sourcemap is present. // Vite will inline and add an additional sourcemap URL for the sourcemap. - code: mapContents ? removeSourceMappingURL(code) : code, - map: mapContents && Buffer.from(mapContents).toString('utf-8'), + code: decodeUtf8(strippedContents), + map: mapContents && decodeUtf8(mapContents), }; }, }; } +/** + * Decodes UTF-8 bytes into a string without copying the underlying memory. + */ +function decodeUtf8(data: Uint8Array): string { + const buffer = Buffer.isBuffer(data) + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + + return buffer.toString('utf-8'); +} + /** * Reads the resolved Vite client code from disk and updates the content to remove * an unactionable suggestion to update the Vite configuration file to disable the diff --git a/packages/angular/build/src/utils/source-map.ts b/packages/angular/build/src/utils/source-map.ts index 4fa97dd222c1..c0bb2ce8698a 100644 --- a/packages/angular/build/src/utils/source-map.ts +++ b/packages/angular/build/src/utils/source-map.ts @@ -8,11 +8,15 @@ import type { EncodedSourceMap } from '@ampproject/remapping'; import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; export const SOURCEMAP_COMMENT_PREFIX = '//# sourceMappingURL='; export const SOURCEMAP_COMMENT_BYTES = Buffer.from(SOURCEMAP_COMMENT_PREFIX); +const SOURCEMAP_COMMENT_VALUE_PREFIX = SOURCEMAP_COMMENT_PREFIX.slice(2); + +let parseSync: typeof import('oxc-parser').parseSync | undefined; /** * Checks for a single trailing `//# sourceMappingURL=` comment on a raw buffer. @@ -59,10 +63,11 @@ export function findTrailingSourceMapComment( /** * Removes `//# sourceMappingURL=` comments safely from the given JavaScript code, - * ignoring any occurrences that are inside string literals, template literals, or block comments. + * ignoring any occurrences that are inside string literals, template literals, regex literals, + * or block comments. * - * For raw Uint8Array / Buffer inputs, it optimizes performance by inspecting trailing byte sequences - * to slice the buffer directly without full string decoding. + * Single trailing sourcemap comments are sliced directly via a fast path; files with multiple + * or non-trailing occurrences lazily fall back to `oxc-parser` comment extraction. * * @param code The JavaScript source code as a string or Uint8Array. * @returns The code with top-level sourcemap comments removed. @@ -83,179 +88,120 @@ export function removeSourceMappingURL(code: string | Uint8Array): string | Uint return trailing.code; } - // Fallback to full decode and state-machine stripping for multiple comments or non-trailing comments + // Fallback to full decode and AST lexer comment stripping for multiple or non-trailing comments const dataBuffer = Buffer.isBuffer(code) ? code : Buffer.from(code.buffer, code.byteOffset, code.byteLength); const text = dataBuffer.toString('utf-8'); - const stripped = removeSourceMappingURLFromString(text); + const stripped = removeSourceMappingURLFromString(text, true); return stripped === text ? code : Buffer.from(stripped, 'utf-8'); } -function removeSourceMappingURLFromString(code: string): string { - if (!code.includes(SOURCEMAP_COMMENT_PREFIX)) { +function removeSourceMappingURLFromString(code: string, skipTrailingCheck = false): string { + const firstIndex = code.indexOf(SOURCEMAP_COMMENT_PREFIX); + if (firstIndex === -1) { return code; } + if (!skipTrailingCheck) { + const lastIndex = code.lastIndexOf(SOURCEMAP_COMMENT_PREFIX); + let prevIdx = lastIndex - 1; + while (prevIdx >= 0 && (code[prevIdx] === ' ' || code[prevIdx] === '\t')) { + prevIdx--; + } + const isLineStart = prevIdx < 0 || code[prevIdx] === '\n' || code[prevIdx] === '\r'; + if ( + firstIndex === lastIndex && + isLineStart && + isTrailingSourceMapComment(code.slice(lastIndex + SOURCEMAP_COMMENT_PREFIX.length)) + ) { + return code.slice(0, prevIdx < 0 ? 0 : prevIdx + 1); + } + } + + parseSync ??= (createRequire(__filename)('oxc-parser') as typeof import('oxc-parser')).parseSync; + + const { comments } = parseSync('source.js', code, { + lang: 'js', + sourceType: 'unambiguous', + }); + const result: string[] = []; let lastCopiedIndex = 0; - let i = 0; - const len = code.length; - - // Stack to track template literal state and curly brace depth for nested interpolations. - const stack: { type: 'template'; braceDepth: number }[] = []; - let currentState: - 'normal' | 'string_double' | 'string_single' | 'template' | 'comment_block' | 'comment_line' = - 'normal'; - - while (i < len) { - const char = code[i]; - const nextChar = code[i + 1]; - - if (currentState === 'normal') { - if (char === '/' && nextChar === '*') { - currentState = 'comment_block'; - i += 2; - continue; - } - if (char === '/' && nextChar === '/') { - // Detect if the comment is escaped (e.g. inside a regex literal like `/\/\/#/`). - let isEscaped = false; - let prevIdx = i - 1; - while (prevIdx >= 0 && /\s/.test(code[prevIdx])) { - prevIdx--; - } - if (prevIdx >= 0 && code[prevIdx] === '\\') { - let bsCount = 0; - while (prevIdx >= 0 && code[prevIdx] === '\\') { - bsCount++; - prevIdx--; - } - if (bsCount % 2 === 1) { - isEscaped = true; - } - } - - if (!isEscaped && code.startsWith(SOURCEMAP_COMMENT_PREFIX, i)) { - if (i > lastCopiedIndex) { - result.push(code.slice(lastCopiedIndex, i)); - } - // Skip the rest of the comment line up to the newline - i += SOURCEMAP_COMMENT_PREFIX.length; - while (i < len && code[i] !== '\n' && code[i] !== '\r') { - i++; - } - lastCopiedIndex = i; - continue; - } else { - currentState = 'comment_line'; - i += 2; - continue; - } - } - if (char === '"') { - currentState = 'string_double'; - i++; - continue; - } - if (char === "'") { - currentState = 'string_single'; - i++; - continue; - } - if (char === '`') { - currentState = 'template'; - stack.push({ type: 'template', braceDepth: 0 }); - i++; - continue; - } - if (char === '{') { - const top = stack[stack.length - 1]; - if (top) { - top.braceDepth++; - } - i++; - continue; - } - if (char === '}') { - const top = stack[stack.length - 1]; - if (top) { - top.braceDepth--; - if (top.braceDepth < 0) { - // Exiting a template literal interpolation ${ ... } - stack.pop(); - currentState = 'template'; - i++; - continue; - } - } - i++; - continue; - } - i++; - } else if (currentState === 'string_double') { - if (char === '\\') { - i += 2; - continue; - } - if (char === '"') { - currentState = 'normal'; - } - i++; - } else if (currentState === 'string_single') { - if (char === '\\') { - i += 2; - continue; - } - if (char === "'") { - currentState = 'normal'; - } - i++; - } else if (currentState === 'template') { - if (char === '\\') { - i += 2; - continue; - } - if (char === '$' && nextChar === '{') { - // Entering template literal interpolation context - currentState = 'normal'; - stack.push({ type: 'template', braceDepth: 0 }); - i += 2; - continue; - } - if (char === '`') { - stack.pop(); - currentState = 'normal'; - } - i++; - } else if (currentState === 'comment_block') { - if (char === '*' && nextChar === '/') { - currentState = 'normal'; - i += 2; - continue; - } - i++; - } else if (currentState === 'comment_line') { - if (char === '\n' || char === '\r') { - currentState = 'normal'; + for (const comment of comments) { + if (comment.type !== 'Line' || !comment.value.startsWith(SOURCEMAP_COMMENT_VALUE_PREFIX)) { + continue; + } + + let start = comment.start; + let end = comment.end; + + // Preserve line terminators on non-trailing comments so subsequent line numbers stay aligned + // with already-generated sourcemaps (and ASI is unaffected when comments follow code). When a + // comment is the only content on its line, strip leading horizontal whitespace—and if only + // whitespace follows to EOF, strip to the end of the file to match the trailing fast path. + let lineStart = start; + while (lineStart > lastCopiedIndex && isHorizontalWhitespace(code.charCodeAt(lineStart - 1))) { + lineStart--; + } + if (lineStart === 0 || isLineTerminator(code.charCodeAt(lineStart - 1))) { + start = lineStart; + if (isWhitespaceToEnd(code, end)) { + end = code.length; } - i++; } + + if (start > lastCopiedIndex) { + result.push(code.slice(lastCopiedIndex, start)); + } + lastCopiedIndex = end; } if (lastCopiedIndex === 0) { return code; } - if (lastCopiedIndex < len) { + if (lastCopiedIndex < code.length) { result.push(code.slice(lastCopiedIndex)); } return result.join(''); } +function isHorizontalWhitespace(code: number): boolean { + return code === 32 || code === 9; // space, tab +} + +function isLineTerminator(code: number): boolean { + return code === 10 || code === 13; // \n, \r +} + +function isWhitespaceToEnd(code: string, start: number): boolean { + for (let i = start; i < code.length; i++) { + const char = code.charCodeAt(i); + if (!isHorizontalWhitespace(char) && !isLineTerminator(char)) { + return false; + } + } + + return true; +} + +function isBase64Char(code: number): boolean { + return ( + (code >= 65 && code <= 90) || // A-Z + (code >= 97 && code <= 122) || // a-z + (code >= 48 && code <= 57) || // 0-9 + code === 43 || // + + code === 47 || // / + code === 61 || // = + code === 45 || // - + code === 95 // _ + ); +} + /** * Extracts the base64 payload from an inline sourcemap data URI line and verifies * that only trailing whitespace follows the payload. @@ -274,13 +220,22 @@ function extractTrailingBase64Payload(urlLine: string): string | undefined { const payloadStart = base64StartIndex + 7; let payloadEnd = urlLine.length; - // Find the first trailing whitespace character that marks the end of the base64 payload. + // Find the first trailing whitespace character that marks the end of the base64 payload, + // while ensuring all payload characters are valid base64 (preventing false positives when + // closing quotes, backticks, or `*/` immediately follow the payload). for (let i = payloadStart; i < urlLine.length; i++) { const char = urlLine[i]; if (char === ' ' || char === '\r' || char === '\n' || char === '\t') { payloadEnd = i; break; } + if (!isBase64Char(urlLine.charCodeAt(i))) { + return undefined; + } + } + + if (payloadEnd === payloadStart) { + return undefined; } // Verify that everything after the base64 payload is trailing whitespace @@ -307,7 +262,7 @@ function extractTrailingUrl(urlLine: string): string | undefined { } const urlMatch = /^([^\r\n\s'"`]+)/.exec(urlLine); - if (!urlMatch) { + if (!urlMatch || urlMatch[1].includes('*/')) { return undefined; } diff --git a/packages/angular/build/src/utils/source-map_spec.ts b/packages/angular/build/src/utils/source-map_spec.ts index a5680d61fa4f..4cc2b2864868 100644 --- a/packages/angular/build/src/utils/source-map_spec.ts +++ b/packages/angular/build/src/utils/source-map_spec.ts @@ -37,6 +37,9 @@ describe('removeSourceMappingURL', () => { it('should not remove sourcemap comments inside block comments', () => { const code = '/*\n//# sourceMappingURL=inline.js.map\n*/'; expect(removeSourceMappingURL(code)).toBe(code); + + const sameLineClose = '/*\n//# sourceMappingURL=inline.js.map*/'; + expect(removeSourceMappingURL(sameLineClose)).toBe(sameLineClose); }); it('should not remove sourcemap comments inside normal single-line comments', () => { @@ -44,12 +47,39 @@ describe('removeSourceMappingURL', () => { expect(removeSourceMappingURL(code)).toBe(code); }); - it('should remove multiple top-level sourcemap comments', () => { + it('should remove multiple top-level sourcemap comments while preserving subsequent line numbers', () => { const code = - '//# sourceMappingURL=first.js.map\nconsole.log("mid");\n//# sourceMappingURL=second.js.map'; + ' //# sourceMappingURL=first.js.map\nconsole.log("mid");\n//# sourceMappingURL=second.js.map'; expect(removeSourceMappingURL(code)).toBe('\nconsole.log("mid");\n'); }); + it('should preserve the line terminator when a sourcemap comment follows code on the same line', () => { + const code = + 'foo(); //# sourceMappingURL=first.js.map\nbar();\n//# sourceMappingURL=second.js.map'; + expect(removeSourceMappingURL(code)).toBe('foo(); \nbar();\n'); + }); + + it('should strip trailing whitespace consistently between the fast path and the fallback', () => { + const trailing = 'a();\n //# sourceMappingURL=main.js.map \n\n'; + // Single trailing comment uses the fast path + expect(removeSourceMappingURL(trailing)).toBe('a();\n'); + // An earlier occurrence forces the oxc fallback while preserving line numbers + expect(removeSourceMappingURL('//# sourceMappingURL=first.js.map\n' + trailing)).toBe( + '\na();\n', + ); + }); + + it('should strip non-trailing sourcemap comments after non-ASCII content', () => { + const code = + 'const s = "héllo 😀 日本";\n//# sourceMappingURL=a.js.map\nconst t = 1;\n//# sourceMappingURL=b.js.map'; + expect(removeSourceMappingURL(code)).toBe('const s = "héllo 😀 日本";\n\nconst t = 1;\n'); + }); + + it('should strip trailing inline sourcemaps using base64url characters via the fast path', () => { + const code = 'a();\n//# sourceMappingURL=data:application/json;base64,eyJ2-_9==\n'; + expect(removeSourceMappingURL(code)).toBe('a();\n'); + }); + it('should not remove sourcemap comments inside strings containing escaped quotes', () => { const codeDouble = 'const str = "escaped \\" //# sourceMappingURL=inline.js.map";'; expect(removeSourceMappingURL(codeDouble)).toBe(codeDouble); @@ -78,14 +108,69 @@ describe('removeSourceMappingURL', () => { expect(removeSourceMappingURL(code)).toBe(code); }); + it('should not remove inline base64 sourcemap comments inside template literals closing on the same line', () => { + const base64 = Buffer.from('{"version":3}').toString('base64'); + const code = `const str = \`\n//# sourceMappingURL=data:application/json;base64,${base64}\`;`; + expect(removeSourceMappingURL(code)).toBe(code); + }); + it('should not remove sourcemap comments inside regex literals', () => { const code = 'const regex = /\\/\\/# sourceMappingURL=inline.js.map/;'; expect(removeSourceMappingURL(code)).toBe(code); }); - it('should not affect normal division operators', () => { - const code = 'const ratio = 10 / 2;\n//# sourceMappingURL=main.js.map'; - expect(removeSourceMappingURL(code)).toBe('const ratio = 10 / 2;\n'); + it('should not corrupt strings following regex literals containing quotes or backticks', () => { + const doubleQuoteCode = + 'const pattern = /"/; const text = "//# sourceMappingURL=not-a-comment";'; + expect(removeSourceMappingURL(doubleQuoteCode)).toBe(doubleQuoteCode); + + const singleQuoteCode = + "const pattern = /'/; const text = '//# sourceMappingURL=not-a-comment';"; + expect(removeSourceMappingURL(singleQuoteCode)).toBe(singleQuoteCode); + + const backtickCode = 'const pattern = /`/; const text = `//# sourceMappingURL=not-a-comment`;'; + expect(removeSourceMappingURL(backtickCode)).toBe(backtickCode); + }); + + it('should handle regex character classes and escapes containing slashes and quotes', () => { + const body = + 'const pattern = /[/"\'`]/; const escaped = /\\/"\\[/; const text = "//# sourceMappingURL=not-a-comment";\n'; + expect(removeSourceMappingURL(body + '//# sourceMappingURL=main.js.map')).toBe(body); + + const charClassComment = 'const re = /[//# sourceMappingURL=foo.js.map]/;'; + expect(removeSourceMappingURL(charClassComment)).toBe(charClassComment); + }); + + it('should handle regex literals after keywords, control-flow conditions, and spread operators', () => { + const code = [ + 'export default /"/;', + 'if (enabled) /"/.test(val) ? doA() : doB();', + 'const matches = [.../"/.exec(str) ?? []];', + 'function* gen() { return [...yield /"/]; }', + 'const text = "//# sourceMappingURL=not-a-comment";', + '//# sourceMappingURL=main.js.map', + ].join('\n'); + + expect(removeSourceMappingURL(code)).toBe( + [ + 'export default /"/;', + 'if (enabled) /"/.test(val) ? doA() : doB();', + 'const matches = [.../"/.exec(str) ?? []];', + 'function* gen() { return [...yield /"/]; }', + 'const text = "//# sourceMappingURL=not-a-comment";', + '', + ].join('\n'), + ); + }); + + it('should not affect division expressions including contextual keywords and postfix operators', () => { + const ratioCode = 'const ratio = 10 / 2;\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(ratioCode)).toBe('const ratio = 10 / 2;\n'); + + const fnBody = + 'function pct(n, of) { const r = of / 100 + i++ / 2;' + + ' const s = "//# sourceMappingURL=not-a-comment"; return r + s; }\n'; + expect(removeSourceMappingURL(fnBody + '//# sourceMappingURL=main.js.map')).toBe(fnBody); }); it('should only remove exact sourceMappingURL prefix comments', () => { @@ -99,8 +184,11 @@ describe('removeSourceMappingURL', () => { }); it('should handle sourcemap comments with CRLF newlines', () => { - const code = 'console.log("hello");\r\n//# sourceMappingURL=main.js.map\r\nconst next = 2;'; - expect(removeSourceMappingURL(code)).toBe('console.log("hello");\r\n\r\nconst next = 2;'); + const midFile = 'console.log("hello");\r\n//# sourceMappingURL=main.js.map\r\nconst next = 2;'; + expect(removeSourceMappingURL(midFile)).toBe('console.log("hello");\r\n\r\nconst next = 2;'); + + const trailing = 'console.log("hello");\r\n//# sourceMappingURL=main.js.map\r\n'; + expect(removeSourceMappingURL(trailing)).toBe('console.log("hello");\r\n'); }); describe('with Uint8Array / Buffer inputs', () => { @@ -131,12 +219,36 @@ describe('removeSourceMappingURL', () => { expect(Buffer.from(result).toString('utf-8')).toBe('\nconsole.log("mid");\n'); }); + it('should strip non-trailing comments after non-ASCII content in a buffer via fallback', () => { + const buffer = Buffer.from( + 'const s = "héllo 😀 日本";\n//# sourceMappingURL=a.js.map\nconst t = 1;\n//# sourceMappingURL=b.js.map', + 'utf-8', + ); + const result = removeSourceMappingURL(buffer); + + expect(Buffer.from(result).toString('utf-8')).toBe( + 'const s = "héllo 😀 日本";\n\nconst t = 1;\n', + ); + }); + it('should not strip sourcemap comments inside template strings in a buffer', () => { const buffer = Buffer.from('const str = `\n//# sourceMappingURL=inline.js.map\n`;', 'utf-8'); const result = removeSourceMappingURL(buffer); expect(Buffer.from(result).toString('utf-8')).toBe(buffer.toString('utf-8')); }); + + it('should preserve strings after regex literals with quotes when falling back on a buffer', () => { + const buffer = Buffer.from( + 'const pattern = /"/; const text = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map', + 'utf-8', + ); + const result = removeSourceMappingURL(buffer); + + expect(Buffer.from(result).toString('utf-8')).toBe( + 'const pattern = /"/; const text = "//# sourceMappingURL=not-a-comment";\n', + ); + }); }); }); @@ -222,4 +334,18 @@ describe('isTrailingSourceMapComment', () => { it('should return false for invalid data URI format', () => { expect(isTrailingSourceMapComment('data:application/json;utf8,{}')).toBe(false); }); + + it('should accept base64url characters in inline data URIs', () => { + expect(isTrailingSourceMapComment('data:application/json;base64,eyJ2-_9==\n')).toBe(true); + }); + + it('should return false when an inline payload is immediately followed by non-base64 characters', () => { + expect(isTrailingSourceMapComment('data:application/json;base64,eyJ2`;')).toBe(false); + expect(isTrailingSourceMapComment('data:application/json;base64,eyJ2*/')).toBe(false); + expect(isTrailingSourceMapComment('data:application/json;base64,')).toBe(false); + }); + + it('should return false when an external URL contains a block comment terminator', () => { + expect(isTrailingSourceMapComment('main.js.map*/')).toBe(false); + }); }); From ce1b638075e4d426adc79d46b27b25ab7ff389d9 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:31:26 -0500 Subject: [PATCH 2/3] fixup! fix(@angular/build): use oxc-parser fallback when stripping source-map comments --- packages/angular/build/src/utils/source-map.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/angular/build/src/utils/source-map.ts b/packages/angular/build/src/utils/source-map.ts index c0bb2ce8698a..f8ec3444f60e 100644 --- a/packages/angular/build/src/utils/source-map.ts +++ b/packages/angular/build/src/utils/source-map.ts @@ -120,7 +120,11 @@ function removeSourceMappingURLFromString(code: string, skipTrailingCheck = fals } } - parseSync ??= (createRequire(__filename)('oxc-parser') as typeof import('oxc-parser')).parseSync; + if (!parseSync) { + const localRequire = createRequire(__filename); + localRequire('../tools/oxc/oxc-wasi-shim'); + parseSync = (localRequire('oxc-parser') as typeof import('oxc-parser')).parseSync; + } const { comments } = parseSync('source.js', code, { lang: 'js', From 69e30518a4862d4158cf68fe252ae7a4cbf7c460 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:25:33 -0500 Subject: [PATCH 3/3] refactor(@angular/build): centralize oxc-parser loading with scoped WASI shim Replace side-effect imports of `oxc-wasi-shim` with a centralized `loadOxcParser()` helper across `@angular/build` consumers. Because `oxc-parser` evaluates its WASI bindings synchronously during `require('oxc-parser')`, `loadOxcParser()` restores `Module._resolveFilename` in a `finally` block immediately after loading. This avoids leaving `@emnapi/core` and `@emnapi/runtime` resolution permanently overridden on the main thread where `rolldown` and `vite` resolve their own `@emnapi/*` versions. --- packages/angular/build/BUILD.bazel | 1 + .../tools/angular/linker/oxc-ast-host_spec.ts | 6 +- .../src/tools/i18n/i18n-inliner-worker.ts | 5 +- .../build/src/tools/oxc/load-oxc-parser.ts | 124 ++++++++++++++++++ .../src/tools/oxc/load-oxc-parser_spec.ts | 114 ++++++++++++++++ .../build/src/tools/oxc/oxc-transform.ts | 5 +- .../build/src/tools/oxc/oxc-wasi-shim.ts | 81 ------------ .../angular/build/src/tools/oxc/traversal.ts | 7 +- .../angular/build/src/utils/source-map.ts | 12 +- 9 files changed, 252 insertions(+), 103 deletions(-) create mode 100644 packages/angular/build/src/tools/oxc/load-oxc-parser.ts create mode 100644 packages/angular/build/src/tools/oxc/load-oxc-parser_spec.ts delete mode 100644 packages/angular/build/src/tools/oxc/oxc-wasi-shim.ts diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index d0ab55750e98..0d1806be151c 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -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", diff --git a/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts b/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts index cbe0d6a25292..a73268706cec 100644 --- a/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts +++ b/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts @@ -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', () => { @@ -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]; } diff --git a/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts b/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts index a84d54eb713b..216423c9bb08 100644 --- a/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts @@ -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, @@ -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'; @@ -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', }); diff --git a/packages/angular/build/src/tools/oxc/load-oxc-parser.ts b/packages/angular/build/src/tools/oxc/load-oxc-parser.ts new file mode 100644 index 000000000000..0369535cf522 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/load-oxc-parser.ts @@ -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; + +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(); + 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; +} diff --git a/packages/angular/build/src/tools/oxc/load-oxc-parser_spec.ts b/packages/angular/build/src/tools/oxc/load-oxc-parser_spec.ts new file mode 100644 index 000000000000..914de016c578 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/load-oxc-parser_spec.ts @@ -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(); + }); +}); diff --git a/packages/angular/build/src/tools/oxc/oxc-transform.ts b/packages/angular/build/src/tools/oxc/oxc-transform.ts index 89594d9462ef..484732488a6d 100644 --- a/packages/angular/build/src/tools/oxc/oxc-transform.ts +++ b/packages/angular/build/src/tools/oxc/oxc-transform.ts @@ -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 { @@ -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); diff --git a/packages/angular/build/src/tools/oxc/oxc-wasi-shim.ts b/packages/angular/build/src/tools/oxc/oxc-wasi-shim.ts deleted file mode 100644 index 3285052be059..000000000000 --- a/packages/angular/build/src/tools/oxc/oxc-wasi-shim.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * @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'; - -// 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. -// -// See: https://github.com/oxc-project/oxc/issues/28717 and https://github.com/napi-rs/napi-rs/issues/3674 -const forceWasi = globalThis.process?.env?.['NAPI_RS_FORCE_WASI']; -if ( - (globalThis.process?.versions?.['webcontainer'] || - forceWasi === 'true' || - forceWasi === 'error') && - !('addonModule' in Object.prototype) -) { - Object.defineProperty(Object.prototype, 'addonModule', { - get() { - return (this as { napiModule?: unknown }).napiModule; - }, - set(value) { - if (this && (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(); - try { - const wasiBindingPath = require.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); - }; - } -} diff --git a/packages/angular/build/src/tools/oxc/traversal.ts b/packages/angular/build/src/tools/oxc/traversal.ts index 7d85e312522a..0e7a42743420 100644 --- a/packages/angular/build/src/tools/oxc/traversal.ts +++ b/packages/angular/build/src/tools/oxc/traversal.ts @@ -6,9 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import './oxc-wasi-shim'; import type { Node } from '@oxc-project/types'; -import { visitorKeys } from 'oxc-parser'; +import { loadOxcParser } from './load-oxc-parser'; + +let visitorKeys: Record | undefined; /** * Contextual scope information provided to AST visitor callbacks during traversal. @@ -276,7 +277,7 @@ function pushChildNodes(node: Node, stack: (Node | null)[]): void { break; } - const keys = visitorKeys[node.type]; + const keys = (visitorKeys ??= loadOxcParser().visitorKeys)[node.type]; if (!keys) { break; } diff --git a/packages/angular/build/src/utils/source-map.ts b/packages/angular/build/src/utils/source-map.ts index f8ec3444f60e..9b1555f4d051 100644 --- a/packages/angular/build/src/utils/source-map.ts +++ b/packages/angular/build/src/utils/source-map.ts @@ -8,16 +8,14 @@ import type { EncodedSourceMap } from '@ampproject/remapping'; import { existsSync, readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { loadOxcParser } from '../tools/oxc/load-oxc-parser'; export const SOURCEMAP_COMMENT_PREFIX = '//# sourceMappingURL='; export const SOURCEMAP_COMMENT_BYTES = Buffer.from(SOURCEMAP_COMMENT_PREFIX); const SOURCEMAP_COMMENT_VALUE_PREFIX = SOURCEMAP_COMMENT_PREFIX.slice(2); -let parseSync: typeof import('oxc-parser').parseSync | undefined; - /** * Checks for a single trailing `//# sourceMappingURL=` comment on a raw buffer. * @@ -120,13 +118,7 @@ function removeSourceMappingURLFromString(code: string, skipTrailingCheck = fals } } - if (!parseSync) { - const localRequire = createRequire(__filename); - localRequire('../tools/oxc/oxc-wasi-shim'); - parseSync = (localRequire('oxc-parser') as typeof import('oxc-parser')).parseSync; - } - - const { comments } = parseSync('source.js', code, { + const { comments } = loadOxcParser().parseSync('source.js', code, { lang: 'js', sourceType: 'unambiguous', });