// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. import path = require("path"); import url = require("url"); import { logger } from "@vscode/debugadapter"; import * as semver from "semver"; import { Request } from "../common/node/request"; import { ensurePackagerRunning } from "../common/packagerStatus"; import { ProjectVersionHelper } from "../common/projectVersionHelper"; import { ErrorHelper } from "../common/error/errorHelper"; import { InternalErrorCode } from "../common/error/internalErrorCode"; import { FileSystem } from "../common/node/fileSystem"; import { SourceMapUtil } from "./sourceMap"; export interface DownloadedScript { contents: string; filepath: string; } interface IStrictUrl extends url.Url { pathname: string; href: string; } export class ScriptImporter { public static DEBUGGER_WORKER_FILE_BASENAME = "debuggerWorker"; public static DEBUGGER_WORKER_FILENAME = `${ScriptImporter.DEBUGGER_WORKER_FILE_BASENAME}.js`; private static readonly REMOVE_SOURCE_URL_VERSION = "0.61.0"; private static readonly DEBUGGER_UI_SUPPORTED_VERSION = "0.50.0"; private packagerAddress: string; private packagerPort: number; private sourcesStoragePath: string; private packagerRemoteRoot?: string; private packagerLocalRoot?: string; private sourceMapUtil: SourceMapUtil; constructor( packagerAddress: string, packagerPort: number, sourcesStoragePath: string, packagerRemoteRoot?: string, packagerLocalRoot?: string, ) { this.packagerAddress = packagerAddress; this.packagerPort = packagerPort; this.sourcesStoragePath = sourcesStoragePath; this.packagerRemoteRoot = packagerRemoteRoot; this.packagerLocalRoot = packagerLocalRoot; this.sourceMapUtil = new SourceMapUtil(); } public async downloadAppScript( scriptUrlString: string, projectRootPath: string, ): Promise { const parsedScriptUrl = url.parse(scriptUrlString); const overriddenScriptUrlString = parsedScriptUrl.hostname === "localhost" ? this.overridePackagerPort(scriptUrlString) : scriptUrlString; // We'll get the source code, and store it locally to have a better debugging experience let scriptBody = await Request.request(overriddenScriptUrlString, true); const rnVersions = await ProjectVersionHelper.getReactNativeVersions(projectRootPath); // unfortunatelly Metro Bundler is broken in RN 0.54.x versions, so use this workaround unless it will be fixed // https://github.com/facebook/metro/issues/147 // https://github.com/microsoft/vscode-react-native/issues/660 if ( ProjectVersionHelper.getRNVersionsWithBrokenMetroBundler().includes( rnVersions.reactNativeVersion, ) ) { const noSourceMappingUrlGenerated = scriptBody.match(/sourceMappingURL=/g) === null; if (noSourceMappingUrlGenerated) { const sourceMapPathUrl = overriddenScriptUrlString.replace("bundle", "map"); scriptBody = this.sourceMapUtil.appendSourceMapPaths(scriptBody, sourceMapPathUrl); } } // Extract sourceMappingURL from body const scriptUrl = url.parse(overriddenScriptUrlString); // scriptUrl = "http://localhost:8081/index.ios.bundle?platform=ios&dev=true" const sourceMappingUrl = this.sourceMapUtil.getSourceMapURL(scriptUrl, scriptBody); // sourceMappingUrl = "http://localhost:8081/index.ios.map?platform=ios&dev=true" let waitForSourceMapping: Promise = Promise.resolve(); if (sourceMappingUrl) { /* handle source map - request it and store it locally */ waitForSourceMapping = this.writeAppSourceMap(sourceMappingUrl, scriptUrl).then(() => { scriptBody = this.sourceMapUtil.updateScriptPaths( scriptBody, sourceMappingUrl, ); if ( semver.gte( rnVersions.reactNativeVersion, ScriptImporter.REMOVE_SOURCE_URL_VERSION, ) || ProjectVersionHelper.isCanaryVersion(rnVersions.reactNativeVersion) ) { scriptBody = this.sourceMapUtil.removeSourceURL(scriptBody); } }); } await waitForSourceMapping; const scriptFilePath = await this.writeAppScript(scriptBody, scriptUrl); logger.verbose(`Script ${overriddenScriptUrlString} downloaded to ${scriptFilePath}`); return { contents: scriptBody, filepath: scriptFilePath }; } public async downloadDebuggerWorker( sourcesStoragePath: string, projectRootPath: string, debuggerWorkerUrlPath?: string, ): Promise { const errPackagerNotRunning = ErrorHelper.getInternalError( InternalErrorCode.CannotAttachToPackagerCheckPackagerRunningOnPort, this.packagerPort, ); await ensurePackagerRunning(this.packagerAddress, this.packagerPort, errPackagerNotRunning); const rnVersions = await ProjectVersionHelper.getReactNativeVersions(projectRootPath); const debuggerWorkerURL = this.prepareDebuggerWorkerURL( rnVersions.reactNativeVersion, debuggerWorkerUrlPath, ); const debuggerWorkerLocalPath = path.join( sourcesStoragePath, ScriptImporter.DEBUGGER_WORKER_FILENAME, ); logger.verbose(`About to download: ${debuggerWorkerURL} to: ${debuggerWorkerLocalPath}`); const body = await Request.request(debuggerWorkerURL, true); return new FileSystem().writeFile(debuggerWorkerLocalPath, body); } public prepareDebuggerWorkerURL(rnVersion: string, debuggerWorkerUrlPath?: string): string { let debuggerWorkerURL: string; // It can be empty string if (debuggerWorkerUrlPath !== undefined) { debuggerWorkerURL = `http://${this.packagerAddress}:${this.packagerPort}/${debuggerWorkerUrlPath}${ScriptImporter.DEBUGGER_WORKER_FILENAME}`; } else { let newPackager = ""; if ( !semver.valid( rnVersion, ) /* Custom RN implementations should support new packager*/ || semver.gte(rnVersion, ScriptImporter.DEBUGGER_UI_SUPPORTED_VERSION) || ProjectVersionHelper.isCanaryVersion(rnVersion) ) { newPackager = "debugger-ui/"; } debuggerWorkerURL = `http://${this.packagerAddress}:${this.packagerPort}/${newPackager}${ScriptImporter.DEBUGGER_WORKER_FILENAME}`; } return debuggerWorkerURL; } /** * Writes the script file to the project temporary location. */ private async writeAppScript(scriptBody: string, scriptUrl: IStrictUrl): Promise { const scriptFilePath = path.join( this.sourcesStoragePath, path.basename(scriptUrl.pathname), ); // scriptFilePath = "$TMPDIR/index.ios.bundle" await new FileSystem().writeFile(scriptFilePath, scriptBody); return scriptFilePath; } /** * Writes the source map file to the project temporary location. */ private async writeAppSourceMap( sourceMapUrl: IStrictUrl, scriptUrl: IStrictUrl, ): Promise { const sourceMapBody = await Request.request(sourceMapUrl.href, true); const sourceMappingLocalPath = path.join( this.sourcesStoragePath, path.basename(sourceMapUrl.pathname), ); // sourceMappingLocalPath = "$TMPDIR/index.ios.map" const scriptFileRelativePath = path.basename(scriptUrl.pathname); // scriptFileRelativePath = "index.ios.bundle" const updatedContent = this.sourceMapUtil.updateSourceMapFile( sourceMapBody, scriptFileRelativePath, this.sourcesStoragePath, this.packagerRemoteRoot, this.packagerLocalRoot, ); return new FileSystem().writeFile(sourceMappingLocalPath, updatedContent); } /** * Changes the port of the url to be the one configured as this.packagerPort */ private overridePackagerPort(urlToOverride: string): string { const components = url.parse(urlToOverride); components.port = this.packagerPort.toString(); delete components.host; // We delete the host, if not the port change will be ignored return url.format(components); } }