Skip to content

Commit 1d06bca

Browse files
committed
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 angular#34590
1 parent ef600f2 commit 1d06bca

4 files changed

Lines changed: 251 additions & 161 deletions

File tree

‎packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,11 @@ export default async function transformJavaScript(
136136
textData = textDecoder.decode(trailing.code);
137137
isAlreadyStripped = true;
138138
} else {
139-
// Fallback to full decode and state-machine stripping
139+
// Not a valid trailing sourcemap: fall back to full decode and oxc comment stripping
140140
textData = textDecoder.decode(data);
141141
}
142142
} else {
143-
// Multiple comments or comment not at line start: fall back to full decode and string parser
143+
// Multiple comments or comment not at line start: fall back to full decode and oxc comment stripping
144144
textData = textDecoder.decode(data);
145145
}
146146
} else {

‎packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,19 +99,30 @@ export async function createAngularMemoryPlugin(
9999
return undefined;
100100
}
101101

102-
const code = Buffer.from(codeContents).toString('utf-8');
103102
const mapContents = outputFiles.get(relativeFile + '.map')?.contents;
103+
const strippedContents = mapContents ? removeSourceMappingURL(codeContents) : codeContents;
104104

105105
return {
106106
// Remove source map URL comments from the code if a sourcemap is present.
107107
// Vite will inline and add an additional sourcemap URL for the sourcemap.
108-
code: mapContents ? removeSourceMappingURL(code) : code,
109-
map: mapContents && Buffer.from(mapContents).toString('utf-8'),
108+
code: decodeUtf8(strippedContents),
109+
map: mapContents && decodeUtf8(mapContents),
110110
};
111111
},
112112
};
113113
}
114114

115+
/**
116+
* Decodes UTF-8 bytes into a string without copying the underlying memory.
117+
*/
118+
function decodeUtf8(data: Uint8Array): string {
119+
const buffer = Buffer.isBuffer(data)
120+
? data
121+
: Buffer.from(data.buffer, data.byteOffset, data.byteLength);
122+
123+
return buffer.toString('utf-8');
124+
}
125+
115126
/**
116127
* Reads the resolved Vite client code from disk and updates the content to remove
117128
* an unactionable suggestion to update the Vite configuration file to disable the

‎packages/angular/build/src/utils/source-map.ts‎

Lines changed: 108 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@
88

99
import type { EncodedSourceMap } from '@ampproject/remapping';
1010
import { existsSync, readFileSync } from 'node:fs';
11+
import { createRequire } from 'node:module';
1112
import { dirname, resolve } from 'node:path';
1213
import { fileURLToPath } from 'node:url';
1314

1415
export const SOURCEMAP_COMMENT_PREFIX = '//# sourceMappingURL=';
1516
export const SOURCEMAP_COMMENT_BYTES = Buffer.from(SOURCEMAP_COMMENT_PREFIX);
17+
const SOURCEMAP_COMMENT_VALUE_PREFIX = SOURCEMAP_COMMENT_PREFIX.slice(2);
18+
19+
let parseSync: typeof import('oxc-parser').parseSync | undefined;
1620

1721
/**
1822
* Checks for a single trailing `//# sourceMappingURL=` comment on a raw buffer.
@@ -59,10 +63,11 @@ export function findTrailingSourceMapComment(
5963

6064
/**
6165
* Removes `//# sourceMappingURL=` comments safely from the given JavaScript code,
62-
* ignoring any occurrences that are inside string literals, template literals, or block comments.
66+
* ignoring any occurrences that are inside string literals, template literals, regex literals,
67+
* or block comments.
6368
*
64-
* For raw Uint8Array / Buffer inputs, it optimizes performance by inspecting trailing byte sequences
65-
* to slice the buffer directly without full string decoding.
69+
* Single trailing sourcemap comments are sliced directly via a fast path; files with multiple
70+
* or non-trailing occurrences lazily fall back to `oxc-parser` comment extraction.
6671
*
6772
* @param code The JavaScript source code as a string or Uint8Array.
6873
* @returns The code with top-level sourcemap comments removed.
@@ -83,179 +88,123 @@ export function removeSourceMappingURL(code: string | Uint8Array): string | Uint
8388
return trailing.code;
8489
}
8590

86-
// Fallback to full decode and state-machine stripping for multiple comments or non-trailing comments
91+
// Fallback to full decode and AST lexer comment stripping for multiple or non-trailing comments
8792
const dataBuffer = Buffer.isBuffer(code)
8893
? code
8994
: Buffer.from(code.buffer, code.byteOffset, code.byteLength);
9095
const text = dataBuffer.toString('utf-8');
91-
const stripped = removeSourceMappingURLFromString(text);
96+
const stripped = removeSourceMappingURLFromString(text, true);
9297

9398
return stripped === text ? code : Buffer.from(stripped, 'utf-8');
9499
}
95100

96-
function removeSourceMappingURLFromString(code: string): string {
97-
if (!code.includes(SOURCEMAP_COMMENT_PREFIX)) {
101+
function removeSourceMappingURLFromString(code: string, skipTrailingCheck = false): string {
102+
const firstIndex = code.indexOf(SOURCEMAP_COMMENT_PREFIX);
103+
if (firstIndex === -1) {
98104
return code;
99105
}
100106

107+
if (!skipTrailingCheck) {
108+
const lastIndex = code.lastIndexOf(SOURCEMAP_COMMENT_PREFIX);
109+
let prevIdx = lastIndex - 1;
110+
while (prevIdx >= 0 && (code[prevIdx] === ' ' || code[prevIdx] === '\t')) {
111+
prevIdx--;
112+
}
113+
const isLineStart = prevIdx < 0 || code[prevIdx] === '\n' || code[prevIdx] === '\r';
114+
if (
115+
firstIndex === lastIndex &&
116+
isLineStart &&
117+
isTrailingSourceMapComment(code.slice(lastIndex + SOURCEMAP_COMMENT_PREFIX.length))
118+
) {
119+
return code.slice(0, prevIdx < 0 ? 0 : prevIdx + 1);
120+
}
121+
}
122+
123+
parseSync ??= (createRequire(__filename)('oxc-parser') as typeof import('oxc-parser')).parseSync;
124+
125+
const { comments } = parseSync('source.js', code, {
126+
lang: 'js',
127+
sourceType: 'unambiguous',
128+
});
129+
101130
const result: string[] = [];
102131
let lastCopiedIndex = 0;
103-
let i = 0;
104-
const len = code.length;
105-
106-
// Stack to track template literal state and curly brace depth for nested interpolations.
107-
const stack: { type: 'template'; braceDepth: number }[] = [];
108-
let currentState:
109-
'normal' | 'string_double' | 'string_single' | 'template' | 'comment_block' | 'comment_line' =
110-
'normal';
111-
112-
while (i < len) {
113-
const char = code[i];
114-
const nextChar = code[i + 1];
115-
116-
if (currentState === 'normal') {
117-
if (char === '/' && nextChar === '*') {
118-
currentState = 'comment_block';
119-
i += 2;
120-
continue;
121-
}
122-
if (char === '/' && nextChar === '/') {
123-
// Detect if the comment is escaped (e.g. inside a regex literal like `/\/\/#/`).
124-
let isEscaped = false;
125-
let prevIdx = i - 1;
126-
while (prevIdx >= 0 && /\s/.test(code[prevIdx])) {
127-
prevIdx--;
128-
}
129-
if (prevIdx >= 0 && code[prevIdx] === '\\') {
130-
let bsCount = 0;
131-
while (prevIdx >= 0 && code[prevIdx] === '\\') {
132-
bsCount++;
133-
prevIdx--;
134-
}
135-
if (bsCount % 2 === 1) {
136-
isEscaped = true;
137-
}
138-
}
139-
140-
if (!isEscaped && code.startsWith(SOURCEMAP_COMMENT_PREFIX, i)) {
141-
if (i > lastCopiedIndex) {
142-
result.push(code.slice(lastCopiedIndex, i));
143-
}
144-
// Skip the rest of the comment line up to the newline
145-
i += SOURCEMAP_COMMENT_PREFIX.length;
146-
while (i < len && code[i] !== '\n' && code[i] !== '\r') {
147-
i++;
148-
}
149-
lastCopiedIndex = i;
150-
continue;
151-
} else {
152-
currentState = 'comment_line';
153-
i += 2;
154-
continue;
155-
}
156-
}
157-
if (char === '"') {
158-
currentState = 'string_double';
159-
i++;
160-
continue;
161-
}
162-
if (char === "'") {
163-
currentState = 'string_single';
164-
i++;
165-
continue;
166-
}
167-
if (char === '`') {
168-
currentState = 'template';
169-
stack.push({ type: 'template', braceDepth: 0 });
170-
i++;
171-
continue;
172-
}
173-
if (char === '{') {
174-
const top = stack[stack.length - 1];
175-
if (top) {
176-
top.braceDepth++;
177-
}
178-
i++;
179-
continue;
180-
}
181-
if (char === '}') {
182-
const top = stack[stack.length - 1];
183-
if (top) {
184-
top.braceDepth--;
185-
if (top.braceDepth < 0) {
186-
// Exiting a template literal interpolation ${ ... }
187-
stack.pop();
188-
currentState = 'template';
189-
i++;
190-
continue;
191-
}
192-
}
193-
i++;
194-
continue;
195-
}
196132

197-
i++;
198-
} else if (currentState === 'string_double') {
199-
if (char === '\\') {
200-
i += 2;
201-
continue;
202-
}
203-
if (char === '"') {
204-
currentState = 'normal';
205-
}
206-
i++;
207-
} else if (currentState === 'string_single') {
208-
if (char === '\\') {
209-
i += 2;
210-
continue;
211-
}
212-
if (char === "'") {
213-
currentState = 'normal';
214-
}
215-
i++;
216-
} else if (currentState === 'template') {
217-
if (char === '\\') {
218-
i += 2;
219-
continue;
220-
}
221-
if (char === '$' && nextChar === '{') {
222-
// Entering template literal interpolation context
223-
currentState = 'normal';
224-
stack.push({ type: 'template', braceDepth: 0 });
225-
i += 2;
226-
continue;
227-
}
228-
if (char === '`') {
229-
stack.pop();
230-
currentState = 'normal';
231-
}
232-
i++;
233-
} else if (currentState === 'comment_block') {
234-
if (char === '*' && nextChar === '/') {
235-
currentState = 'normal';
236-
i += 2;
237-
continue;
238-
}
239-
i++;
240-
} else if (currentState === 'comment_line') {
241-
if (char === '\n' || char === '\r') {
242-
currentState = 'normal';
133+
for (const comment of comments) {
134+
if (comment.type !== 'Line' || !comment.value.startsWith(SOURCEMAP_COMMENT_VALUE_PREFIX)) {
135+
continue;
136+
}
137+
138+
let start = comment.start;
139+
let end = comment.end;
140+
141+
// Match the trailing fast path: when the comment is the only content on its line, remove the
142+
// entire line (or everything up to EOF if only whitespace follows). Comments that follow code
143+
// on the same line keep their line terminator to avoid altering automatic semicolon insertion.
144+
let lineStart = start;
145+
while (lineStart > lastCopiedIndex && isHorizontalWhitespace(code.charCodeAt(lineStart - 1))) {
146+
lineStart--;
147+
}
148+
if (lineStart === 0 || isLineTerminator(code.charCodeAt(lineStart - 1))) {
149+
start = lineStart;
150+
if (isWhitespaceToEnd(code, end)) {
151+
end = code.length;
152+
} else if (code.charCodeAt(end) === 13 && code.charCodeAt(end + 1) === 10) {
153+
end += 2;
154+
} else if (isLineTerminator(code.charCodeAt(end))) {
155+
end++;
243156
}
244-
i++;
245157
}
158+
159+
if (start > lastCopiedIndex) {
160+
result.push(code.slice(lastCopiedIndex, start));
161+
}
162+
lastCopiedIndex = end;
246163
}
247164

248165
if (lastCopiedIndex === 0) {
249166
return code;
250167
}
251168

252-
if (lastCopiedIndex < len) {
169+
if (lastCopiedIndex < code.length) {
253170
result.push(code.slice(lastCopiedIndex));
254171
}
255172

256173
return result.join('');
257174
}
258175

176+
function isHorizontalWhitespace(code: number): boolean {
177+
return code === 32 || code === 9; // space, tab
178+
}
179+
180+
function isLineTerminator(code: number): boolean {
181+
return code === 10 || code === 13; // \n, \r
182+
}
183+
184+
function isWhitespaceToEnd(code: string, start: number): boolean {
185+
for (let i = start; i < code.length; i++) {
186+
const char = code.charCodeAt(i);
187+
if (!isHorizontalWhitespace(char) && !isLineTerminator(char)) {
188+
return false;
189+
}
190+
}
191+
192+
return true;
193+
}
194+
195+
function isBase64Char(code: number): boolean {
196+
return (
197+
(code >= 65 && code <= 90) || // A-Z
198+
(code >= 97 && code <= 122) || // a-z
199+
(code >= 48 && code <= 57) || // 0-9
200+
code === 43 || // +
201+
code === 47 || // /
202+
code === 61 || // =
203+
code === 45 || // -
204+
code === 95 // _
205+
);
206+
}
207+
259208
/**
260209
* Extracts the base64 payload from an inline sourcemap data URI line and verifies
261210
* that only trailing whitespace follows the payload.
@@ -274,13 +223,22 @@ function extractTrailingBase64Payload(urlLine: string): string | undefined {
274223

275224
const payloadStart = base64StartIndex + 7;
276225
let payloadEnd = urlLine.length;
277-
// Find the first trailing whitespace character that marks the end of the base64 payload.
226+
// Find the first trailing whitespace character that marks the end of the base64 payload,
227+
// while ensuring all payload characters are valid base64 (preventing false positives when
228+
// closing quotes, backticks, or `*/` immediately follow the payload).
278229
for (let i = payloadStart; i < urlLine.length; i++) {
279230
const char = urlLine[i];
280231
if (char === ' ' || char === '\r' || char === '\n' || char === '\t') {
281232
payloadEnd = i;
282233
break;
283234
}
235+
if (!isBase64Char(urlLine.charCodeAt(i))) {
236+
return undefined;
237+
}
238+
}
239+
240+
if (payloadEnd === payloadStart) {
241+
return undefined;
284242
}
285243

286244
// Verify that everything after the base64 payload is trailing whitespace
@@ -307,7 +265,7 @@ function extractTrailingUrl(urlLine: string): string | undefined {
307265
}
308266

309267
const urlMatch = /^([^\r\n\s'"`]+)/.exec(urlLine);
310-
if (!urlMatch) {
268+
if (!urlMatch || urlMatch[1].includes('*/')) {
311269
return undefined;
312270
}
313271

0 commit comments

Comments
 (0)