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/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/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/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..9b1555f4d051 100644 --- a/packages/angular/build/src/utils/source-map.ts +++ b/packages/angular/build/src/utils/source-map.ts @@ -10,9 +10,11 @@ import type { EncodedSourceMap } from '@ampproject/remapping'; import { existsSync, readFileSync } from 'node:fs'; 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); /** * Checks for a single trailing `//# sourceMappingURL=` comment on a raw buffer. @@ -59,10 +61,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 +86,118 @@ 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); + } + } + + const { comments } = loadOxcParser().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 +216,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 +258,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); + }); });