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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import { type PluginItem, transformAsync } from '@babel/core';
import { createRequire } from 'node:module';
import Piscina from 'piscina';
import { useBabelLinker } from '../../utils/environment-options.js';
import { loadInputSourceMap, removeSourceMappingURL } from '../../utils/source-map';
import {
isTrailingSourceMapComment,
loadInputSourceMap,
loadInputSourceMapFromUrl,
removeSourceMappingURL,
} from '../../utils/source-map';

interface JavaScriptTransformRequest {
filename: string;
Expand All @@ -25,8 +30,14 @@ interface JavaScriptTransformRequest {
instrumentForCoverage?: boolean;
}

interface TransformOptions extends Omit<JavaScriptTransformRequest, 'filename' | 'data'> {
inputSourceMap?: EncodedSourceMap;
isAlreadyStripped?: boolean;
}

const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
const SOURCEMAP_COMMENT_BYTES = Buffer.from('//# sourceMappingURL=');

/**
* The function name prefix for all Angular partial compilation functions.
Expand Down Expand Up @@ -84,9 +95,70 @@ export default async function transformJavaScript(
request: JavaScriptTransformRequest,
): Promise<unknown> {
const { filename, data, ...options } = request;
const textData = typeof data === 'string' ? data : textDecoder.decode(data);

const transformedData = await transformJavaScriptImpl(filename, textData, options);
const useInputSourcemap =
options.sourcemap &&
(!!options.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));

let textData: string;
let inputSourceMap: EncodedSourceMap | undefined;
let isAlreadyStripped = false;

if (typeof data !== 'string') {
const dataBuffer = Buffer.isBuffer(data)
? data
: Buffer.from(data.buffer, data.byteOffset, data.byteLength);

const firstIndex = dataBuffer.indexOf(SOURCEMAP_COMMENT_BYTES);
if (firstIndex === -1) {
// 0 comments: fast path, no sourcemap to load or strip
textData = textDecoder.decode(data);
isAlreadyStripped = true;
} else {
const lastIndex = dataBuffer.lastIndexOf(SOURCEMAP_COMMENT_BYTES);
let prevIdx = lastIndex - 1;
while (prevIdx >= 0 && (dataBuffer[prevIdx] === 32 || dataBuffer[prevIdx] === 9)) {
prevIdx--;
}
const isLineStart = prevIdx < 0 || dataBuffer[prevIdx] === 10 || dataBuffer[prevIdx] === 13;

if (firstIndex === lastIndex && isLineStart) {
const urlLine = dataBuffer
.subarray(lastIndex + SOURCEMAP_COMMENT_BYTES.length)
.toString('utf-8');

if (useInputSourcemap) {
inputSourceMap = loadInputSourceMapFromUrl(filename, urlLine);
if (inputSourceMap !== undefined) {
// Valid trailing sourcemap comment confirmed: safe to slice code buffer
textData = textDecoder.decode(dataBuffer.subarray(0, prevIdx < 0 ? 0 : prevIdx + 1));
isAlreadyStripped = true;
} else {
// Not a valid trailing sourcemap (e.g. inside template literal): fallback to full decode
textData = textDecoder.decode(data);
}
} else if (isTrailingSourceMapComment(urlLine)) {
// Valid trailing sourcemap comment confirmed: safe to slice code buffer
textData = textDecoder.decode(dataBuffer.subarray(0, prevIdx < 0 ? 0 : prevIdx + 1));
isAlreadyStripped = true;
} else {
// Fallback to full decode and state-machine stripping
textData = textDecoder.decode(data);
}
} else {
// Multiple comments or comment not at line start: fall back to full decode and string parser
textData = textDecoder.decode(data);
}
}
} else {
textData = data;
}

const transformedData = await transformJavaScriptImpl(filename, textData, {
...options,
inputSourceMap,
isAlreadyStripped,
});

// Transfer the data via `move` instead of cloning
if (transformedData === textData && typeof data !== 'string') {
Expand All @@ -109,7 +181,7 @@ let oxcTransformModule: typeof import('../oxc/oxc-transform.js') | undefined;
async function transformJavaScriptImpl(
filename: string,
data: string,
options: Omit<JavaScriptTransformRequest, 'filename' | 'data'>,
options: TransformOptions,
): Promise<string> {
const shouldLink = !options.skipLinker && requiresLinking(filename, data);
const useInputSourcemap =
Expand Down Expand Up @@ -194,9 +266,11 @@ async function transformJavaScriptImpl(
}

if (useInputSourcemap) {
const baseMap = coverageMap ?? loadInputSourceMap(filename, data);
const baseMap = coverageMap ?? options.inputSourceMap ?? loadInputSourceMap(filename, data);
if (maps.length > 0 || coverageMap) {
code = removeSourceMappingURL(code);
if (!options.isAlreadyStripped) {
code = removeSourceMappingURL(code);
}
const remappingChain: (DecodedSourceMap | EncodedSourceMap)[] = maps.reverse();
if (baseMap) {
remappingChain.push(baseMap);
Expand All @@ -213,7 +287,7 @@ async function transformJavaScriptImpl(
}

// Strip sourcemaps if they should not be used
return removeSourceMappingURL(code);
return options.isAlreadyStripped ? code : removeSourceMappingURL(code);
}

function requiresLinking(path: string, source: string): boolean {
Expand Down
146 changes: 101 additions & 45 deletions packages/angular/build/src/utils/source-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,71 +184,101 @@ export function removeSourceMappingURL(code: string): string {
}

/**
* Finds, resolves, and loads the input sourcemap referenced in the code's trailing
* sourceMappingURL comment, if present. Supports inline base64 data URIs, local absolute
* file URLs, and relative/absolute filesystem paths.
* Extracts the base64 payload from an inline sourcemap data URI line and verifies
* that only trailing whitespace follows the payload.
*
* @returns The base64 payload string if valid and trailing, or `undefined` otherwise.
*/
export function loadInputSourceMap(filename: string, code: string): EncodedSourceMap | undefined {
// Locate the last sourceMappingURL comment using lastIndexOf to avoid scanning
// the entire file with a regular expression (significant for large files).
const lastSourceMapIndex = code.lastIndexOf('//# sourceMappingURL=');
if (lastSourceMapIndex === -1) {
function extractTrailingBase64Payload(urlLine: string): string | undefined {
if (!urlLine.startsWith('data:application/json;')) {
return undefined;
}

const urlLine = code.slice(lastSourceMapIndex + 21);

// Inline base64-encoded sourcemaps can be extremely large (up to megabytes).
// Parse them without regular expressions to avoid heavy backtracking and allocations.
if (urlLine.startsWith('data:application/json;')) {
const base64StartIndex = urlLine.indexOf('base64,');
if (base64StartIndex === -1) {
return undefined;
}
const base64StartIndex = urlLine.indexOf('base64,');
if (base64StartIndex === -1) {
return undefined;
}

const payloadStart = base64StartIndex + 7;
let payloadEnd = urlLine.length;
// Find the first trailing whitespace character that marks the end of the base64 payload.
for (let i = payloadStart; i < urlLine.length; i++) {
const char = urlLine[i];
if (char === ' ' || char === '\r' || char === '\n' || char === '\t') {
payloadEnd = i;
break;
}
const payloadStart = base64StartIndex + 7;
let payloadEnd = urlLine.length;
// Find the first trailing whitespace character that marks the end of the base64 payload.
for (let i = payloadStart; i < urlLine.length; i++) {
const char = urlLine[i];
if (char === ' ' || char === '\r' || char === '\n' || char === '\t') {
payloadEnd = i;
break;
}
}

// Verify that everything after the base64 payload is trailing whitespace
// to ensure this is a valid trailing sourceMappingURL comment at the end of the file.
for (let i = payloadEnd; i < urlLine.length; i++) {
const char = urlLine[i];
if (char !== ' ' && char !== '\r' && char !== '\n' && char !== '\t') {
return undefined;
}
// Verify that everything after the base64 payload is trailing whitespace
// to ensure this is a valid trailing sourceMappingURL comment at the end of the file.
for (let i = payloadEnd; i < urlLine.length; i++) {
const char = urlLine[i];
if (char !== ' ' && char !== '\r' && char !== '\n' && char !== '\t') {
return undefined;
}
}

try {
// Extract the base64 payload and decode it directly into binary memory.
const base64Content = urlLine.slice(payloadStart, payloadEnd);
return urlLine.slice(payloadStart, payloadEnd);
}

return JSON.parse(Buffer.from(base64Content, 'base64').toString('utf-8')) as EncodedSourceMap;
} catch {
return undefined;
}
/**
* Extracts the URL from an external sourcemap comment line and verifies
* that only trailing whitespace follows the URL.
*
* @returns The URL string if valid and trailing, or `undefined` otherwise.
*/
function extractTrailingUrl(urlLine: string): string | undefined {
if (urlLine.startsWith('data:')) {
return undefined;
}

// Non-inline sourcemap comments (always small, typically < 200 characters).
const urlMatch = /^([^\r\n\s]+)/.exec(urlLine);
const urlMatch = /^([^\r\n\s'"`]+)/.exec(urlLine);
if (!urlMatch) {
return undefined;
}

const url = urlMatch[1];
const remaining = urlLine.slice(url.length);
// Verify there is only whitespace after the URL to the end of the file.
const remaining = urlLine.slice(urlMatch[1].length);
if (!/^\s*$/.test(remaining)) {
return undefined;
}

return urlMatch[1];
}

/**
* Checks whether a `//# sourceMappingURL=` URL line snippet represents a valid trailing comment at the end of the file.
*/
export function isTrailingSourceMapComment(urlLine: string): boolean {
return (
extractTrailingBase64Payload(urlLine) !== undefined || extractTrailingUrl(urlLine) !== undefined
);
}

/**
* Resolves and loads the input sourcemap referenced in a `//# sourceMappingURL=` URL line snippet.
* Supports inline base64 data URIs, local absolute file URLs, and relative/absolute filesystem paths.
*/
export function loadInputSourceMapFromUrl(
filename: string,
urlLine: string,
): EncodedSourceMap | undefined {
// Inline base64-encoded sourcemaps can be extremely large (up to megabytes).
// Parse them without regular expressions to avoid heavy backtracking and allocations.
const base64Payload = extractTrailingBase64Payload(urlLine);
if (base64Payload !== undefined) {
try {
return JSON.parse(Buffer.from(base64Payload, 'base64').toString('utf-8')) as EncodedSourceMap;
} catch {
return undefined;
}
}

const url = extractTrailingUrl(urlLine);
if (!url) {
return undefined;
}

if (url.startsWith('file://')) {
// Local absolute file URL scheme.
try {
Expand All @@ -269,3 +299,29 @@ export function loadInputSourceMap(filename: string, code: string): EncodedSourc

return undefined;
}

/**
* Finds, resolves, and loads the input sourcemap referenced in the code's trailing
* sourceMappingURL comment, if present. Supports inline base64 data URIs, local absolute
* file URLs, and relative/absolute filesystem paths.
*/
export function loadInputSourceMap(filename: string, code: string): EncodedSourceMap | undefined {
// Locate the last sourceMappingURL comment using lastIndexOf to avoid scanning
// the entire file with a regular expression (significant for large files).
const lastSourceMapIndex = code.lastIndexOf('//# sourceMappingURL=');
if (lastSourceMapIndex === -1) {
return undefined;
}

if (lastSourceMapIndex > 0) {
let prevIdx = lastSourceMapIndex - 1;
while (prevIdx >= 0 && (code[prevIdx] === ' ' || code[prevIdx] === '\t')) {
prevIdx--;
}
if (prevIdx >= 0 && code[prevIdx] !== '\n' && code[prevIdx] !== '\r') {
return undefined;
}
}

return loadInputSourceMapFromUrl(filename, code.slice(lastSourceMapIndex + 21));
}
Loading