// 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 * as vscode from "vscode";
import * as WebSocket from "ws";
import { EventEmitter } from "events";
import { ensurePackagerRunning } from "../common/packagerStatus";
import { ErrorHelper } from "../common/error/errorHelper";
import { logger } from "vscode-debugadapter";
import { ExecutionsLimiter } from "../common/executionsLimiter";
import { ForkedAppWorker } from "./forkedAppWorker";
import { ScriptImporter } from "./scriptImporter";
import { ReactNativeProjectHelper } from "../common/reactNativeProjectHelper";
import * as nls from "vscode-nls";
import { InternalErrorCode } from "../common/error/internalErrorCode";
import { FileSystem } from "../common/node/fileSystem";
import { PromiseUtil } from "../common/node/promise";
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<any>;
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
});
// TODO: Replace by url.fileURLToPath method when Node 10 LTS become deprecated
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;
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<void> {
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<void> {
let 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<void> {
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<void> {
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
*/
let msgKey = "_closeMessage";
if (this.socketToApp[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(() => {
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
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);
let object = <RNAppMessage>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: string = "";
try {
stringified = JSON.stringify(message);
logger.verbose(`To RN APP: ${stringified}`);
this.socketToApp.send(stringified);
} catch (exception) {
let messageToShow = stringified || "" + message; // Try to show the stringified version, but show the toString if unavailable
printDebuggingError(
ErrorHelper.getInternalError(
InternalErrorCode.FailedToSendMessageToTheReactNativeApp,
messageToShow,
),
exception,
);
}
}
}microsoft/vscode-react-native
Publicmirrored fromhttps://github.com/microsoft/vscode-react-nativeAvailable
src/debugger/appWorker.ts
482lines · modepreview