import { decode, SourceMapMappings, type SourceMapSegment } from "@jridgewell/sourcemap-codec"; import { StackFrame } from "./parse-stack.js"; export interface DecodedSourceMapSection { map: { file?: string; mappings: SourceMapSegment[][]; names?: string[]; sourceRoot?: string; sources: string[]; sourcesContent?: string[]; version: 3; }; offset: { column: number; line: number; }; } // https://tc39.es/ecma426/#sec-index-source-map export interface IndexSourceMap { file?: string; sections: Array<{ map: StandardSourceMap; offset: { column: number; line: number; }; }>; version: 3; } export type RawSourceMap = IndexSourceMap | StandardSourceMap; export interface SourceMap { file?: string; mappings: SourceMapSegment[][]; names?: string[]; sections?: DecodedSourceMapSection[]; sourceRoot?: string; sources: string[]; sourcesContent?: string[]; version: 3; } // https://developer.chrome.com/blog/sourcemaps#the_anatomy_of_a_source_map export interface StandardSourceMap { file?: string; mappings: string; names?: string[]; sourceRoot?: string; sources: string[]; sourcesContent?: string[]; version: 3; } // has a scheme, e.g. http://, https://, file://, data:, etc. // https://datatracker.ietf.org/doc/html/rfc3986#section-3.1 const SCHEME_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; // inline sourcemap, e.g. data:application/json;base64,... const INLINE_SOURCEMAP_REGEX = /^data:application\/json[^,]+base64,/; // sourcemap url, e.g. //@ sourceMappingURL=... or /* @ sourceMappingURL=... */ at the end of the file const SOURCEMAP_REGEX = /(?:\/\/[@#][ \t]+sourceMappingURL=([^\s'"]+?)[ \t]*$)|(?:\/\*[@#][ \t]+sourceMappingURL=([^*]+?)[ \t]*(?:\*\/)[ \t]*$)/; export const sourceMapCache = new Map(); const _pendingSourceMapRequests = new Map>(); const getSourceFromMappings = ( mappings: SourceMapMappings, sources: string[], lineIndexInMappings: number, column: number, ): StackFrame | null => { if (lineIndexInMappings < 0 || lineIndexInMappings >= mappings.length) { return null; } const lineMapping = mappings[lineIndexInMappings]; if (!lineMapping || lineMapping.length === 0) { return null; } let closestLineSegment: null | SourceMapSegment = null; for (const lineSegment of lineMapping) { if (lineSegment[0] <= column) { closestLineSegment = lineSegment; } else { break; } } if (!closestLineSegment || closestLineSegment.length < 4) { return null; } const [, sourceIndex, sourceLine, sourceColumn] = closestLineSegment; if (sourceIndex === undefined || sourceLine === undefined || sourceColumn === undefined) { return null; } const fileName = sources[sourceIndex]; if (!fileName) { return null; } return { columnNumber: sourceColumn, fileName, lineNumber: sourceLine + 1, }; }; export const getSourceFromSourceMap = ( sourceMap: SourceMap, line: number, column: number, ): StackFrame | null => { if (sourceMap.sections) { let targetSection: DecodedSourceMapSection | null = null; for (const section of sourceMap.sections) { if ( line > section.offset.line || (line === section.offset.line && column >= section.offset.column) ) { targetSection = section; } else { break; } } if (!targetSection) { return null; } const relativeLine = line - targetSection.offset.line; const relativeColumn = line === targetSection.offset.line ? column - targetSection.offset.column : column; return getSourceFromMappings( targetSection.map.mappings, targetSection.map.sources, relativeLine, relativeColumn, ); } return getSourceFromMappings(sourceMap.mappings, sourceMap.sources, line - 1, column); }; const getSourceMapUrl = (url: string, content: string): null | string => { const lines = content.split("\n"); let sourceMapUrl: string | undefined; for (let i = lines.length - 1; i >= 0 && !sourceMapUrl; i--) { const regexMatch = lines[i].match(SOURCEMAP_REGEX); if (regexMatch) { sourceMapUrl = regexMatch[1] || regexMatch[2]; } } if (!sourceMapUrl) { return null; } const hasScheme = SCHEME_REGEX.test(sourceMapUrl); if (!(INLINE_SOURCEMAP_REGEX.test(sourceMapUrl) || hasScheme || sourceMapUrl.startsWith("/"))) { const urlSegments = url.split("/"); urlSegments[urlSegments.length - 1] = sourceMapUrl; sourceMapUrl = urlSegments.join("/"); } return sourceMapUrl; }; const decodeStandardSourceMap = (rawSourceMap: StandardSourceMap): SourceMap => ({ file: rawSourceMap.file, mappings: decode(rawSourceMap.mappings), names: rawSourceMap.names, sourceRoot: rawSourceMap.sourceRoot, sources: rawSourceMap.sources, sourcesContent: rawSourceMap.sourcesContent, version: 3, }); const decodeIndexSourceMap = (rawSourceMap: IndexSourceMap): SourceMap => { const decodedSections: DecodedSourceMapSection[] = rawSourceMap.sections.map( ({ map, offset }) => ({ map: { ...map, mappings: decode(map.mappings), }, offset, }), ); const allSources = new Set(); for (const section of decodedSections) { for (const source of section.map.sources) { allSources.add(source); } } return { file: rawSourceMap.file, mappings: [], names: [], sections: decodedSections, sourceRoot: undefined, sources: Array.from(allSources), sourcesContent: undefined, version: 3, }; }; const isFetchableUrl = (url: string): boolean => { if (!url) { return false; } const trimmedUrl = url.trim(); if (!trimmedUrl) { return false; } const schemeMatch = trimmedUrl.match(SCHEME_REGEX); if (!schemeMatch) { return true; } const scheme = schemeMatch[0].toLowerCase(); return scheme === "http:" || scheme === "https:"; }; export const getSourceMapImpl = async ( bundleUrl: string, fetchFn: (url: string) => Promise = fetch, ): Promise => { if (!isFetchableUrl(bundleUrl)) { return null; } let bundleContent: string | undefined; try { const bundleResponse = await fetchFn(bundleUrl); if (!bundleResponse.ok) { return null; } bundleContent = await bundleResponse.text(); } catch { return null; } if (!bundleContent) { return null; } const sourceMapUrl = getSourceMapUrl(bundleUrl, bundleContent); if (!sourceMapUrl) return null; if (!isFetchableUrl(sourceMapUrl)) { return null; } try { const sourceMapResponse = await fetchFn(sourceMapUrl); if (!sourceMapResponse.ok) { return null; } const rawSourceMap = (await sourceMapResponse.json()) as RawSourceMap; return "sections" in rawSourceMap ? decodeIndexSourceMap(rawSourceMap) : decodeStandardSourceMap(rawSourceMap); } catch { return null; } }; export const getSourceMap = async ( file: string, useCache = true, fetchFn?: (url: string) => Promise, ): Promise => { if (useCache && sourceMapCache.has(file)) { const cachedValue = sourceMapCache.get(file); if (cachedValue === null || cachedValue === undefined) { return null; } return cachedValue; } if (useCache && _pendingSourceMapRequests.has(file)) { return _pendingSourceMapRequests.get(file)!; } const fetchPromise = getSourceMapImpl(file, fetchFn); if (useCache) { _pendingSourceMapRequests.set(file, fetchPromise); } const sourceMap = await fetchPromise; if (useCache) { _pendingSourceMapRequests.delete(file); } if (useCache) { if (sourceMap === null) { sourceMapCache.set(file, null); } else { sourceMapCache.set(file, sourceMap); } } return sourceMap; }; export const symbolicateStack = async ( stack: StackFrame[], cache = true, fetchFn?: (url: string) => Promise, ): Promise => { return await Promise.all( stack.map(async (stackFrame) => { if (!stackFrame.fileName) return stackFrame; const sourceMap = await getSourceMap(stackFrame.fileName, cache, fetchFn); if ( !sourceMap || typeof stackFrame.lineNumber !== "number" || typeof stackFrame.columnNumber !== "number" ) { return stackFrame; } const symbolicatedSource = getSourceFromSourceMap( sourceMap, stackFrame.lineNumber, stackFrame.columnNumber, ); if (!symbolicatedSource) return stackFrame; return { ...stackFrame, source: symbolicatedSource.fileName && stackFrame.source ? stackFrame.source.replace(stackFrame.fileName, symbolicatedSource.fileName) : stackFrame.source, fileName: symbolicatedSource.fileName, lineNumber: symbolicatedSource.lineNumber, columnNumber: symbolicatedSource.columnNumber, isSymbolicated: true, }; }), ); };