// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. import * as path from "path"; import { EventEmitter } from "events"; import * as vscode from "vscode"; import WebSocket = require("ws"); import { logger } from "@vscode/debugadapter"; import * as nls from "vscode-nls"; import { ensurePackagerRunning } from "../common/packagerStatus"; import { ErrorHelper } from "../common/error/errorHelper"; import { ExecutionsLimiter } from "../common/executionsLimiter"; import { ReactNativeProjectHelper } from "../common/reactNativeProjectHelper"; import { InternalErrorCode } from "../common/error/internalErrorCode"; import { FileSystem } from "../common/node/fileSystem"; import { PromiseUtil } from "../common/node/promise"; import { ForkedAppWorker } from "./forkedAppWorker"; import { ScriptImporter } from "./scriptImporter"; nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone, })(); const localize = nls.loadMessageBundle(); export interface RNAppMessage { method: string; url?: string; // These objects have also other properties but that we don't currently use } export interface IDebuggeeWorker { start(): Promise; stop(): void; postMessage(message: RNAppMessage): void; } function printDebuggingError(error: Error, reason: any) { const nestedError = ErrorHelper.getNestedError( error, InternalErrorCode.DebuggingWontWorkReloadJSAndReconnect, reason, ); logger.error(nestedError.message); } /** This class will create a SandboxedAppWorker that will run the RN App logic, and then create a socket * and send the RN App messages to the SandboxedAppWorker. The only RN App message that this class handles * is the prepareJSRuntime, which we reply to the RN App that the sandbox was created successfully. * When the socket closes, we'll create a new SandboxedAppWorker and a new socket pair and discard the old ones. */ export class MultipleLifetimesAppWorker extends EventEmitter { public static WORKER_BOOTSTRAP = ` // Initialize some variables before react-native code would access them var onmessage=null, self=global; // Cache Node's original require as __debug__.require global.__debug__={require: require}; // Prevent leaking process.versions from debugger process to // worker because pure React Native doesn't do that and some packages as js-md5 rely on this behavior Object.defineProperty(process, "versions", { value: undefined }); // Polyfill for url.fileURLToPath - needed because this code runs in user's RN environment // where we cannot use import/require. Keep this implementation. function fileUrlToPath(url) { if (process.platform === 'win32') { return url.toString().replace('file:///', ''); } else { return url.toString().replace('file://', ''); } } function getNativeModules() { var NativeModules; try { // This approach is for old RN versions NativeModules = global.require('NativeModules'); } catch (err) { // ignore error and try another way for more recent RN versions try { var nativeModuleId; var modules = global.__r.getModules(); var ids = Object.keys(modules); for (var i = 0; i < ids.length; i++) { if (modules[ids[i]].verboseName) { var packagePath = new String(modules[ids[i]].verboseName); if (packagePath.indexOf('Libraries/BatchedBridge/NativeModules.js') > 0 || packagePath.indexOf('Libraries\\\\BatchedBridge\\\\NativeModules.js') > 0) { nativeModuleId = parseInt(ids[i], 10); break; } } } if (nativeModuleId) { NativeModules = global.__r(nativeModuleId); } } catch (err) { // suppress errors } } return NativeModules; } // Originally, this was made for iOS only var vscodeHandlers = { 'vscode_reloadApp': function () { var NativeModules = getNativeModules(); if (NativeModules && NativeModules.DevSettings) { NativeModules.DevSettings.reload(); } }, 'vscode_showDevMenu': function () { var NativeModules = getNativeModules(); if (NativeModules && NativeModules.DevMenu) { NativeModules.DevMenu.show(); } } }; process.on("message", function (message) { if (message.data && vscodeHandlers[message.data.method]) { vscodeHandlers[message.data.method](); } else if(onmessage) { onmessage(message); } }); var postMessage = function(message){ process.send(message); }; if (!self.postMessage) { self.postMessage = postMessage; } var importScripts = (function(){ var fs=require('fs'), vm=require('vm'); return function(scriptUrl){ scriptUrl = fileUrlToPath(scriptUrl); var scriptCode = fs.readFileSync(scriptUrl, 'utf8'); // Add a 'debugger;' statement to stop code execution // to wait for the sourcemaps to be processed by the debug adapter vm.runInThisContext('debugger;' + scriptCode, {filename: scriptUrl}); }; })(); `; public static CONSOLE_TRACE_PATCH = `// Worker is ran as nodejs process, so console.trace() writes to stderr and it leads to error in native app // To avoid this console.trace() is overridden to print stacktrace via console.log() // Please, see Node JS implementation: https://github.com/nodejs/node/blob/master/lib/internal/console/constructor.js console.trace = (function() { return function() { try { var err = { name: 'Trace', message: require('util').format.apply(null, arguments) }; // Node uses 10, but usually it's not enough for RN app trace Error.stackTraceLimit = 30; Error.captureStackTrace(err, console.trace); console.log(err.stack); } catch (e) { console.error(e); } }; })(); `; public static PROCESS_TO_STRING_PATCH = `// As worker is ran in node, it breaks broadcast-channels package approach of identifying if it’s ran in node: // https://github.com/pubkey/broadcast-channel/blob/master/src/util.js#L64 // To avoid it if process.toString() is called if will return empty string instead of [object process]. var nativeObjectToString = Object.prototype.toString; Object.prototype.toString = function() { if (this === process) { return ''; } else { return nativeObjectToString.call(this); } }; `; public static WORKER_DONE = `// Notify debugger that we're done with loading // and started listening for IPC messages postMessage({workerLoaded:true});`; public static FETCH_STUB = `(function(self) { 'use strict'; if (self.fetch) { return; } self.fetch = fetch; function fetch(url) { return new Promise((resolve, reject) => { var data = require('fs').readFileSync(fileUrlToPath(url), 'utf8'); resolve( { text: function () { return data; } }); }); } })(global); `; private packagerAddress: string; private packagerPort: number; private sourcesStoragePath: string; private projectRootPath: string; private packagerRemoteRoot?: string; private packagerLocalRoot?: string; private debuggerWorkerUrlPath?: string; private socketToApp!: WebSocket; private cancellationToken: vscode.CancellationToken; private singleLifetimeWorker: IDebuggeeWorker | null = null; private webSocketConstructor: (url: string) => WebSocket; private executionLimiter = new ExecutionsLimiter(); private nodeFileSystem = new FileSystem(); private scriptImporter: ScriptImporter; constructor( attachRequestArguments: any, sourcesStoragePath: string, projectRootPath: string, cancellationToken: vscode.CancellationToken, { webSocketConstructor = (url: string) => new WebSocket(url) } = {}, ) { super(); this.packagerAddress = attachRequestArguments.address || "localhost"; this.packagerPort = attachRequestArguments.port; this.packagerRemoteRoot = attachRequestArguments.remoteRoot; this.packagerLocalRoot = attachRequestArguments.localRoot; this.debuggerWorkerUrlPath = attachRequestArguments.debuggerWorkerUrlPath; this.sourcesStoragePath = sourcesStoragePath; this.projectRootPath = projectRootPath; this.cancellationToken = cancellationToken; if (!this.sourcesStoragePath) throw ErrorHelper.getInternalError(InternalErrorCode.SourcesStoragePathIsNullOrEmpty); this.webSocketConstructor = webSocketConstructor; this.scriptImporter = new ScriptImporter( this.packagerAddress, this.packagerPort, sourcesStoragePath, this.packagerRemoteRoot, this.packagerLocalRoot, ); } public async start(retryAttempt: boolean = false): Promise { const errPackagerNotRunning = ErrorHelper.getInternalError( InternalErrorCode.CannotAttachToPackagerCheckPackagerRunningOnPort, this.packagerPort, ); await ensurePackagerRunning(this.packagerAddress, this.packagerPort, errPackagerNotRunning); // Don't fetch debugger worker on socket disconnect if (!retryAttempt) { await this.downloadAndPatchDebuggerWorker(); } return this.createSocketToApp(retryAttempt); } public stop(): void { if (this.socketToApp) { this.socketToApp.removeAllListeners(); this.socketToApp.close(); } if (this.singleLifetimeWorker) { this.singleLifetimeWorker.stop(); } } public async downloadAndPatchDebuggerWorker(): Promise { const scriptToRunPath = path.resolve( this.sourcesStoragePath, ScriptImporter.DEBUGGER_WORKER_FILENAME, ); await this.scriptImporter.downloadDebuggerWorker( this.sourcesStoragePath, this.projectRootPath, this.debuggerWorkerUrlPath, ); const workerContent = await this.nodeFileSystem.readFile(scriptToRunPath, "utf8"); const isHaulProject = ReactNativeProjectHelper.isHaulProject(this.projectRootPath); // Add our customizations to debugger worker to get it working smoothly // in Node env and polyfill WebWorkers API over Node's IPC. const modifiedDebuggeeContent = [ MultipleLifetimesAppWorker.WORKER_BOOTSTRAP, MultipleLifetimesAppWorker.CONSOLE_TRACE_PATCH, MultipleLifetimesAppWorker.PROCESS_TO_STRING_PATCH, isHaulProject ? MultipleLifetimesAppWorker.FETCH_STUB : null, workerContent, MultipleLifetimesAppWorker.WORKER_DONE, ].join("\n"); return this.nodeFileSystem.writeFile(scriptToRunPath, modifiedDebuggeeContent); } public showDevMenuCommand(): void { if (this.singleLifetimeWorker) { this.singleLifetimeWorker.postMessage({ method: "vscode_showDevMenu", }); } } public reloadAppCommand(): void { if (this.singleLifetimeWorker) { this.singleLifetimeWorker.postMessage({ method: "vscode_reloadApp", }); } } private async startNewWorkerLifetime(): Promise { this.singleLifetimeWorker = new ForkedAppWorker( this.packagerAddress, this.packagerPort, this.sourcesStoragePath, this.projectRootPath, message => { this.sendMessageToApp(message); }, this.packagerRemoteRoot, this.packagerLocalRoot, ); logger.verbose("A new app worker lifetime was created."); const startedEvent = await this.singleLifetimeWorker.start(); this.emit("connected", startedEvent); } private async createSocketToApp(retryAttempt: boolean = false): Promise { return new Promise((resolve, reject) => { this.socketToApp = this.webSocketConstructor(this.debuggerProxyUrl()); this.socketToApp.on("open", () => { this.onSocketOpened(); }); this.socketToApp.on("close", () => { this.executionLimiter.execute("onSocketClose.msg", /* limitInSeconds*/ 10, () => { /* * It is not the best idea to compare with the message, but this is the only thing React Native gives that is unique when * it closes the socket because it already has a connection to a debugger. * https://github.com/facebook/react-native/blob/588f01e9982775f0699c7bfd56623d4ed3949810/local-cli/server/util/webSocketProxy.js#L38 */ const msgKey = "_closeMessage"; if ( (this.socketToApp as any)[msgKey] === "Another debugger is already connected" ) { reject( ErrorHelper.getInternalError( InternalErrorCode.AnotherDebuggerConnectedToPackager, ), ); } logger.log( localize( "DisconnectedFromThePackagerToReactNative", "Disconnected from the Proxy (Packager) to the React Native application. Retrying reconnection soon...", ), ); }); if (!this.cancellationToken.isCancellationRequested) { setTimeout(() => { void this.start(true /* retryAttempt */); }, 100); } }); this.socketToApp.on("message", (message: any) => this.onMessage(message)); this.socketToApp.on("error", (error: Error) => { if (retryAttempt) { printDebuggingError( ErrorHelper.getInternalError( InternalErrorCode.ReconnectionToPackagerFailedCheckForErrorsOrRestartReactNative, ), error, ); } reject(error); }); // In an attempt to catch failures in starting the packager on first attempt, // wait for 300 ms before resolving the promise void PromiseUtil.delay(300).then(() => resolve()); }); } private debuggerProxyUrl() { return `ws://${this.packagerAddress}:${this.packagerPort}/debugger-proxy?role=debugger&name=vscode`; } private onSocketOpened() { this.executionLimiter.execute("onSocketOpened.msg", /* limitInSeconds*/ 10, () => logger.log( localize( "EstablishedConnectionWithPackagerToReactNativeApp", "Established a connection with the Proxy (Packager) to the React Native application", ), ), ); } private killWorker() { if (!this.singleLifetimeWorker) return; this.singleLifetimeWorker.stop(); this.singleLifetimeWorker = null; } private onMessage(message: string) { try { logger.verbose(`From RN APP: ${message}`); const object = JSON.parse(message); if (object.method === "prepareJSRuntime") { // In RN 0.40 Android runtime doesn't seem to be sending "$disconnected" event // when user reloads an app, hence we need to try to kill it here either. this.killWorker(); // The MultipleLifetimesAppWorker will handle prepareJSRuntime aka create new lifetime this.gotPrepareJSRuntime(object); } else if (object.method === "$disconnected") { // We need to shutdown the current app worker, and create a new lifetime this.killWorker(); } else if (object.method) { // All the other messages are handled by the single lifetime worker if (this.singleLifetimeWorker) { this.singleLifetimeWorker.postMessage(object); } } else { // Message doesn't have a method. Ignore it. This is an info message instead of warn because it's normal and expected logger.verbose( `The react-native app sent a message without specifying a method: ${message}`, ); } } catch (exception) { printDebuggingError( ErrorHelper.getInternalError( InternalErrorCode.FailedToProcessMessageFromReactNativeApp, message, ), exception, ); } } private gotPrepareJSRuntime(message: any): void { // Create the sandbox, and replay that we finished processing the message this.startNewWorkerLifetime().then( () => { this.sendMessageToApp({ replyID: parseInt(message.id, 10) }); }, error => printDebuggingError( ErrorHelper.getInternalError( InternalErrorCode.FailedToPrepareJSRuntimeEnvironment, message, ), error, ), ); } private sendMessageToApp(message: any): void { let stringified = ""; try { stringified = JSON.stringify(message); logger.verbose(`To RN APP: ${stringified}`); this.socketToApp.send(stringified); } catch (exception) { const messageToShow = stringified || String(message); // Try to show the stringified version, but show the toString if unavailable printDebuggingError( ErrorHelper.getInternalError( InternalErrorCode.FailedToSendMessageToTheReactNativeApp, messageToShow, ), exception, ); } } }