|
8 | 8 |
|
9 | 9 | import type { EncodedSourceMap } from '@ampproject/remapping'; |
10 | 10 | import { existsSync, readFileSync } from 'node:fs'; |
| 11 | +import { createRequire } from 'node:module'; |
11 | 12 | import { dirname, resolve } from 'node:path'; |
12 | 13 | import { fileURLToPath } from 'node:url'; |
13 | 14 |
|
14 | 15 | export const SOURCEMAP_COMMENT_PREFIX = '//# sourceMappingURL='; |
15 | 16 | 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; |
16 | 20 |
|
17 | 21 | /** |
18 | 22 | * Checks for a single trailing `//# sourceMappingURL=` comment on a raw buffer. |
@@ -59,10 +63,11 @@ export function findTrailingSourceMapComment( |
59 | 63 |
|
60 | 64 | /** |
61 | 65 | * 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. |
63 | 68 | * |
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. |
66 | 71 | * |
67 | 72 | * @param code The JavaScript source code as a string or Uint8Array. |
68 | 73 | * @returns The code with top-level sourcemap comments removed. |
@@ -83,179 +88,120 @@ export function removeSourceMappingURL(code: string | Uint8Array): string | Uint |
83 | 88 | return trailing.code; |
84 | 89 | } |
85 | 90 |
|
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 |
87 | 92 | const dataBuffer = Buffer.isBuffer(code) |
88 | 93 | ? code |
89 | 94 | : Buffer.from(code.buffer, code.byteOffset, code.byteLength); |
90 | 95 | const text = dataBuffer.toString('utf-8'); |
91 | | - const stripped = removeSourceMappingURLFromString(text); |
| 96 | + const stripped = removeSourceMappingURLFromString(text, true); |
92 | 97 |
|
93 | 98 | return stripped === text ? code : Buffer.from(stripped, 'utf-8'); |
94 | 99 | } |
95 | 100 |
|
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) { |
98 | 104 | return code; |
99 | 105 | } |
100 | 106 |
|
| 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 | + |
101 | 130 | const result: string[] = []; |
102 | 131 | 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 | | - } |
196 | 132 |
|
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 | + // Preserve line terminators on non-trailing comments so subsequent line numbers stay aligned |
| 142 | + // with already-generated sourcemaps (and ASI is unaffected when comments follow code). When a |
| 143 | + // comment is the only content on its line, strip leading horizontal whitespace—and if only |
| 144 | + // whitespace follows to EOF, strip to the end of the file to match the trailing fast path. |
| 145 | + let lineStart = start; |
| 146 | + while (lineStart > lastCopiedIndex && isHorizontalWhitespace(code.charCodeAt(lineStart - 1))) { |
| 147 | + lineStart--; |
| 148 | + } |
| 149 | + if (lineStart === 0 || isLineTerminator(code.charCodeAt(lineStart - 1))) { |
| 150 | + start = lineStart; |
| 151 | + if (isWhitespaceToEnd(code, end)) { |
| 152 | + end = code.length; |
243 | 153 | } |
244 | | - i++; |
245 | 154 | } |
| 155 | + |
| 156 | + if (start > lastCopiedIndex) { |
| 157 | + result.push(code.slice(lastCopiedIndex, start)); |
| 158 | + } |
| 159 | + lastCopiedIndex = end; |
246 | 160 | } |
247 | 161 |
|
248 | 162 | if (lastCopiedIndex === 0) { |
249 | 163 | return code; |
250 | 164 | } |
251 | 165 |
|
252 | | - if (lastCopiedIndex < len) { |
| 166 | + if (lastCopiedIndex < code.length) { |
253 | 167 | result.push(code.slice(lastCopiedIndex)); |
254 | 168 | } |
255 | 169 |
|
256 | 170 | return result.join(''); |
257 | 171 | } |
258 | 172 |
|
| 173 | +function isHorizontalWhitespace(code: number): boolean { |
| 174 | + return code === 32 || code === 9; // space, tab |
| 175 | +} |
| 176 | + |
| 177 | +function isLineTerminator(code: number): boolean { |
| 178 | + return code === 10 || code === 13; // \n, \r |
| 179 | +} |
| 180 | + |
| 181 | +function isWhitespaceToEnd(code: string, start: number): boolean { |
| 182 | + for (let i = start; i < code.length; i++) { |
| 183 | + const char = code.charCodeAt(i); |
| 184 | + if (!isHorizontalWhitespace(char) && !isLineTerminator(char)) { |
| 185 | + return false; |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + return true; |
| 190 | +} |
| 191 | + |
| 192 | +function isBase64Char(code: number): boolean { |
| 193 | + return ( |
| 194 | + (code >= 65 && code <= 90) || // A-Z |
| 195 | + (code >= 97 && code <= 122) || // a-z |
| 196 | + (code >= 48 && code <= 57) || // 0-9 |
| 197 | + code === 43 || // + |
| 198 | + code === 47 || // / |
| 199 | + code === 61 || // = |
| 200 | + code === 45 || // - |
| 201 | + code === 95 // _ |
| 202 | + ); |
| 203 | +} |
| 204 | + |
259 | 205 | /** |
260 | 206 | * Extracts the base64 payload from an inline sourcemap data URI line and verifies |
261 | 207 | * that only trailing whitespace follows the payload. |
@@ -274,13 +220,22 @@ function extractTrailingBase64Payload(urlLine: string): string | undefined { |
274 | 220 |
|
275 | 221 | const payloadStart = base64StartIndex + 7; |
276 | 222 | let payloadEnd = urlLine.length; |
277 | | - // Find the first trailing whitespace character that marks the end of the base64 payload. |
| 223 | + // Find the first trailing whitespace character that marks the end of the base64 payload, |
| 224 | + // while ensuring all payload characters are valid base64 (preventing false positives when |
| 225 | + // closing quotes, backticks, or `*/` immediately follow the payload). |
278 | 226 | for (let i = payloadStart; i < urlLine.length; i++) { |
279 | 227 | const char = urlLine[i]; |
280 | 228 | if (char === ' ' || char === '\r' || char === '\n' || char === '\t') { |
281 | 229 | payloadEnd = i; |
282 | 230 | break; |
283 | 231 | } |
| 232 | + if (!isBase64Char(urlLine.charCodeAt(i))) { |
| 233 | + return undefined; |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + if (payloadEnd === payloadStart) { |
| 238 | + return undefined; |
284 | 239 | } |
285 | 240 |
|
286 | 241 | // Verify that everything after the base64 payload is trailing whitespace |
@@ -307,7 +262,7 @@ function extractTrailingUrl(urlLine: string): string | undefined { |
307 | 262 | } |
308 | 263 |
|
309 | 264 | const urlMatch = /^([^\r\n\s'"`]+)/.exec(urlLine); |
310 | | - if (!urlMatch) { |
| 265 | + if (!urlMatch || urlMatch[1].includes('*/')) { |
311 | 266 | return undefined; |
312 | 267 | } |
313 | 268 |
|
|