bippy
Version:
hack into react internals
360 lines (307 loc) • 9.13 kB
text/typescript
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<string, null | SourceMap>();
const _pendingSourceMapRequests = new Map<string, null | Promise<null | SourceMap>>();
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<string>();
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<Response> = fetch,
): Promise<null | SourceMap> => {
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<Response>,
): Promise<null | SourceMap> => {
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<Response>,
): Promise<StackFrame[]> => {
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,
};
}),
);
};