// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. import * as url from "url"; import * as path from "path"; import { RawSourceMap } from "source-map"; import { SourceMapsCombinator } from "./sourceMapsCombinator"; const IS_REMOTE = /^[A-Za-z]{2,}:\/\//; // Detection remote sources or specific protocols (like "webpack:///") interface ISourceMap extends RawSourceMap { sections?: ISourceMapSection[]; } interface ISourceMapSection { map: ISourceMap | null; offset: { column: number; line: number }; } export interface IStrictUrl extends url.Url { pathname: string; href: string; } export class SourceMapUtil { private static SourceMapURLGlobalRegex: RegExp = /\/\/(#|@) sourceMappingURL=((?!data:)[^ ]+?)\s*$/gm; private static SourceMapURLRegex: RegExp = /\/\/(#|@) sourceMappingURL=((?!data:)[^ ]+?)\s*$/m; private static SourceURLRegex: RegExp = /^\/\/[#@] ?sourceURL=(.+)$/m; /** * Given a script body and URL, this method parses the body and finds the corresponding source map URL. * If the source map URL is not found in the body in the expected form, null is returned. */ public getSourceMapURL(scriptUrl: url.Url, scriptBody: string): IStrictUrl | null { let result: IStrictUrl | null = null; // scriptUrl = "http://localhost:8081/index.ios.bundle?platform=ios&dev=true" const sourceMappingRelativeUrl = this.getSourceMapRelativeUrl(scriptBody); // sourceMappingRelativeUrl = "/index.ios.map?platform=ios&dev=true" if (sourceMappingRelativeUrl) { const sourceMappingUrl = url.parse(sourceMappingRelativeUrl); sourceMappingUrl.protocol = scriptUrl.protocol; sourceMappingUrl.host = scriptUrl.host; // parse() repopulates all the properties of the URL result = url.parse(url.format(sourceMappingUrl)); } return result; } /** * Updates the contents of a source map file to be VS Code friendly: * - makes source paths unix style and relative to the sources root path * - updates the url of the script file * - deletes the script content from the source map * * @parameter sourceMapBody - body of the source map as generated by the RN Packager. * @parameter scriptPath - path of the script file asssociated with this source map. * @parameter sourcesRootPath - root path of sources * */ public updateSourceMapFile( sourceMapBody: string, scriptPath: string, sourcesRootPath: string, packagerRemoteRoot?: string, packagerLocalRoot?: string, ): string { try { let sourceMap = JSON.parse(sourceMapBody); if (sourceMap.sections) { // Preserve indexed sourcemap offsets even when Metro emits a null section map. sourceMap.sections = sourceMap.sections.map(section => ({ ...section, map: section.map ?? SourceMapUtil.createEmptySourceMap(), })); sourceMap = SourceMapUtil.flattenIndexedSourceMap(sourceMap); } const sourceMapsCombinator = new SourceMapsCombinator(); sourceMap = sourceMapsCombinator.convert(sourceMap); if (sourceMap.sources) { sourceMap.sources = sourceMap.sources.map(sourcePath => IS_REMOTE.test(sourcePath) ? sourcePath : this.updateSourceMapPath( sourcePath, sourcesRootPath, packagerRemoteRoot, packagerLocalRoot, ), ); } delete sourceMap.sourcesContent; sourceMap.sourceRoot = ""; sourceMap.file = scriptPath; return JSON.stringify(sourceMap); } catch (exception) { return sourceMapBody; } } public appendSourceMapPaths(scriptBody: string, sourceMappingUrl: string): string { scriptBody += `//# sourceMappingURL=${sourceMappingUrl}`; return scriptBody; } /** * Updates source map URLs in the script body. */ public updateScriptPaths(scriptBody: string, sourceMappingUrl: IStrictUrl): string { const sourceMapMatch = this.searchSourceMapURL(scriptBody); if (sourceMapMatch) { // Update the body with the new location of the source map on storage. return scriptBody.replace( sourceMapMatch[0], `//# sourceMappingURL=${path.basename(sourceMappingUrl.pathname)}`, ); } return scriptBody; } /** * Removes sourceURL from the script body since RN 0.61 because it breaks sourcemaps. * Example: //# sourceURL=http://localhost:8081/index.bundle?platform=android&dev=true&minify=false -> "" */ public removeSourceURL(scriptBody: string): string { return scriptBody.replace(SourceMapUtil.SourceURLRegex, ""); } /** * Parses the body of a script searching for a source map URL. * It supports the following source map url styles: * * `//# sourceMappingURL=path/to/source/map` * * `//@ sourceMappingURL=path/to/source/map` * * Returns the last match if found, null otherwise. */ public getSourceMapRelativeUrl(body: string): string | null { const sourceMapMatch = this.searchSourceMapURL(body); // If match is null, the body doesn't contain the source map if (sourceMapMatch) { // On React Native macOS 0.62 and RN Windows 0.65 sourceMappingUrl looks like: // # sourceMappingURL=//localhost:8081/index.map?platform=macos&dev=true&minify=false // Add 'http:' protocol to avoid errors in further processing const el = sourceMapMatch[2]; const macOsOrWin = (el.includes("platform=macos") || el.includes("platform=window")) && el.startsWith("//") && !el.includes("http:"); return macOsOrWin ? `http:${el}` : el; } return null; } private searchSourceMapURL(str: string): RegExpMatchArray | null { const matchesList = str .match(SourceMapUtil.SourceMapURLGlobalRegex) ?.filter(s => !s.includes("\\n")); if (matchesList && matchesList.length) { return matchesList[matchesList.length - 1].match(SourceMapUtil.SourceMapURLRegex); } return null; } /** * Given an absolute source path, this method does two things: * 1. It changes the path from absolute to be relative to the sourcesRootPath parameter. * 2. It changes the path separators to Unix style. */ private updateSourceMapPath( sourcePath: string, sourcesRootPath: string, packagerRemoteRoot?: string, packagerLocalRoot?: string, ) { if (packagerRemoteRoot && packagerLocalRoot) { packagerRemoteRoot = this.makeUnixStylePath(packagerRemoteRoot); packagerLocalRoot = this.makeUnixStylePath(packagerLocalRoot); sourcePath = sourcePath.replace(packagerRemoteRoot, packagerLocalRoot); } const relativeSourcePath = path.relative(sourcesRootPath, sourcePath); return this.makeUnixStylePath(relativeSourcePath); } /** * Visual Studio Code source mapping requires Unix style path separators. * This method replaces all back-slash characters in a given string with forward-slash ones. */ private makeUnixStylePath(p: string): string { const pathArgs = p.split(path.sep); return path.posix.join.apply(null, pathArgs); } private static createEmptySourceMap(): ISourceMap { return { version: 3, sources: [], names: [], mappings: "", file: "", }; } private static flattenIndexedSourceMap(sourceMap: ISourceMap): ISourceMap { // eslint-disable-next-line @typescript-eslint/no-var-requires const flattenSourceMap = require("flatten-source-map"); const meaningfulSections = (sourceMap.sections ?? []).filter( section => section.map && (section.map.sources?.length || section.map.mappings), ); if (meaningfulSections.length === 1 && meaningfulSections[0].map) { return meaningfulSections[0].map; } try { return flattenSourceMap(sourceMap); } catch (error) { if (!meaningfulSections.length) { throw error; } const firstOffset = meaningfulSections[0].offset; const rebasedSections = meaningfulSections.map(section => ({ ...section, offset: { line: Math.max(0, section.offset.line - firstOffset.line), column: section.offset.line === firstOffset.line ? Math.max(0, section.offset.column - firstOffset.column) : section.offset.column, }, })); return flattenSourceMap({ ...sourceMap, sections: rebasedSections, }); } } }