microsoft/vscode-react-native

Public

mirrored fromhttps://github.com/microsoft/vscode-react-nativeAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1.0.0

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

src/debugger/appWorker.ts

411lines · modecode

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT license. See LICENSE file in the project root for details.
3
4import * as path from "path";
5import * as WebSocket from "ws";
6import { EventEmitter } from "events";
7import { ensurePackagerRunning } from "../common/packagerStatus";
8import {ErrorHelper} from "../common/error/errorHelper";
9import { logger } from "vscode-debugadapter";
10import {ExecutionsLimiter} from "../common/executionsLimiter";
11import { ForkedAppWorker } from "./forkedAppWorker";
12import { ScriptImporter } from "./scriptImporter";
13import { ReactNativeProjectHelper } from "../common/reactNativeProjectHelper";
14import * as nls from "vscode-nls";
15import { InternalErrorCode } from "../common/error/internalErrorCode";
16import { FileSystem } from "../common/node/fileSystem";
17import { PromiseUtil } from "../common/node/promise";
18nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })();
19const localize = nls.loadMessageBundle();
20
21export interface RNAppMessage {
22 method: string;
23 url?: string;
24 // These objects have also other properties but that we don't currently use
25}
26
27export interface IDebuggeeWorker {
28 start(): Promise<any>;
29 stop(): void;
30 postMessage(message: RNAppMessage): void;
31}
32
33function printDebuggingError(error: Error, reason: any) {
34 const nestedError = ErrorHelper.getNestedError(error, InternalErrorCode.DebuggingWontWorkReloadJSAndReconnect, reason);
35
36 logger.error(nestedError.message);
37}
38
39 /** This class will create a SandboxedAppWorker that will run the RN App logic, and then create a socket
40 * and send the RN App messages to the SandboxedAppWorker. The only RN App message that this class handles
41 * is the prepareJSRuntime, which we reply to the RN App that the sandbox was created successfully.
42 * When the socket closes, we'll create a new SandboxedAppWorker and a new socket pair and discard the old ones.
43 */
44
45export class MultipleLifetimesAppWorker extends EventEmitter {
46 public static WORKER_BOOTSTRAP = `
47// Initialize some variables before react-native code would access them
48var onmessage=null, self=global;
49// Cache Node's original require as __debug__.require
50global.__debug__={require: require};
51// Prevent leaking process.versions from debugger process to
52// worker because pure React Native doesn't do that and some packages as js-md5 rely on this behavior
53Object.defineProperty(process, "versions", {
54 value: undefined
55});
56
57// TODO: Replace by url.fileURLToPath method when Node 10 LTS become deprecated
58function fileUrlToPath(url) {
59 if (process.platform === 'win32') {
60 return url.toString().replace('file:///', '');
61 } else {
62 return url.toString().replace('file://', '');
63 }
64}
65
66function getNativeModules() {
67 var NativeModules;
68 try {
69 // This approach is for old RN versions
70 NativeModules = global.require('NativeModules');
71 } catch (err) {
72 // ignore error and try another way for more recent RN versions
73 try {
74 var nativeModuleId;
75 var modules = global.__r.getModules();
76 var ids = Object.keys(modules);
77 for (var i = 0; i < ids.length; i++) {
78 if (modules[ids[i]].verboseName) {
79 var packagePath = new String(modules[ids[i]].verboseName);
80 if (packagePath.indexOf('react-native/Libraries/BatchedBridge/NativeModules.js') > 0) {
81 nativeModuleId = parseInt(ids[i], 10);
82 break;
83 }
84 }
85 }
86 if (nativeModuleId) {
87 NativeModules = global.__r(nativeModuleId);
88 }
89 }
90 catch (err) {
91 // suppress errors
92 }
93 }
94 return NativeModules;
95}
96
97// Originally, this was made for iOS only
98var vscodeHandlers = {
99 'vscode_reloadApp': function () {
100 var NativeModules = getNativeModules();
101 if (NativeModules && NativeModules.DevMenu) {
102 NativeModules.DevMenu.reload();
103 }
104 },
105 'vscode_showDevMenu': function () {
106 var NativeModules = getNativeModules();
107 if (NativeModules && NativeModules.DevMenu) {
108 NativeModules.DevMenu.show();
109 }
110 }
111};
112
113process.on("message", function (message) {
114 if (message.data && vscodeHandlers[message.data.method]) {
115 vscodeHandlers[message.data.method]();
116 } else if(onmessage) {
117 onmessage(message);
118 }
119});
120
121var postMessage = function(message){
122 process.send(message);
123};
124
125if (!self.postMessage) {
126 self.postMessage = postMessage;
127}
128
129var importScripts = (function(){
130 var fs=require('fs'), vm=require('vm');
131 return function(scriptUrl){
132 scriptUrl = fileUrlToPath(scriptUrl);
133 var scriptCode = fs.readFileSync(scriptUrl, 'utf8');
134 // Add a 'debugger;' statement to stop code execution
135 // to wait for the sourcemaps to be processed by the debug adapter
136 vm.runInThisContext('debugger;' + scriptCode, {filename: scriptUrl});
137 };
138})();
139`;
140
141 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
142// To avoid this console.trace() is overridden to print stacktrace via console.log()
143// Please, see Node JS implementation: https://github.com/nodejs/node/blob/master/lib/internal/console/constructor.js
144console.trace = (function() {
145 return function() {
146 try {
147 var err = {
148 name: 'Trace',
149 message: require('util').format.apply(null, arguments)
150 };
151 // Node uses 10, but usually it's not enough for RN app trace
152 Error.stackTraceLimit = 30;
153 Error.captureStackTrace(err, console.trace);
154 console.log(err.stack);
155 } catch (e) {
156 console.error(e);
157 }
158 };
159})();
160`;
161
162 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:
163// https://github.com/pubkey/broadcast-channel/blob/master/src/util.js#L64
164// To avoid it if process.toString() is called if will return empty string instead of [object process].
165var nativeObjectToString = Object.prototype.toString;
166Object.prototype.toString = function() {
167 if (this === process) {
168 return '';
169 } else {
170 return nativeObjectToString.call(this);
171 }
172};
173`;
174
175 public static WORKER_DONE = `// Notify debugger that we're done with loading
176// and started listening for IPC messages
177postMessage({workerLoaded:true});`;
178
179 public static FETCH_STUB = `(function(self) {
180'use strict';
181
182if (self.fetch) {
183 return;
184}
185
186self.fetch = fetch;
187
188function fetch(url) {
189 return new Promise((resolve, reject) => {
190 var data = require('fs').readFileSync(fileUrlToPath(url), 'utf8');
191 resolve(
192 {
193 text: function () {
194 return data;
195 }
196 });
197 });
198}
199})(global);
200`;
201
202 private packagerAddress: string;
203 private packagerPort: number;
204 private sourcesStoragePath: string;
205 private projectRootPath: string;
206 private packagerRemoteRoot?: string;
207 private packagerLocalRoot?: string;
208 private debuggerWorkerUrlPath?: string;
209 private socketToApp: WebSocket;
210 private singleLifetimeWorker: IDebuggeeWorker | null;
211 private webSocketConstructor: (url: string) => WebSocket;
212
213 private executionLimiter = new ExecutionsLimiter();
214 private nodeFileSystem = new FileSystem();
215 private scriptImporter: ScriptImporter;
216
217 constructor(
218 attachRequestArguments: any,
219 sourcesStoragePath: string,
220 projectRootPath: string,
221 {
222 webSocketConstructor = (url: string) => new WebSocket(url),
223 } = {}) {
224 super();
225 this.packagerAddress = attachRequestArguments.address || "localhost";
226 this.packagerPort = attachRequestArguments.port;
227 this.packagerRemoteRoot = attachRequestArguments.remoteRoot;
228 this.packagerLocalRoot = attachRequestArguments.localRoot;
229 this.debuggerWorkerUrlPath = attachRequestArguments.debuggerWorkerUrlPath;
230 this.sourcesStoragePath = sourcesStoragePath;
231 this.projectRootPath = projectRootPath;
232 if (!this.sourcesStoragePath)
233 throw ErrorHelper.getInternalError(InternalErrorCode.SourcesStoragePathIsNullOrEmpty);
234 this.webSocketConstructor = webSocketConstructor;
235 this.scriptImporter = new ScriptImporter(this.packagerAddress, this.packagerPort, sourcesStoragePath, this.packagerRemoteRoot, this.packagerLocalRoot);
236 }
237
238 public start(retryAttempt: boolean = false): Promise<any> {
239 const errPackagerNotRunning = ErrorHelper.getInternalError(InternalErrorCode.CannotAttachToPackagerCheckPackagerRunningOnPort, this.packagerPort);
240
241 return ensurePackagerRunning(this.packagerAddress, this.packagerPort, errPackagerNotRunning)
242 .then(() => {
243 // Don't fetch debugger worker on socket disconnect
244 return retryAttempt ? Promise.resolve() :
245 this.downloadAndPatchDebuggerWorker();
246 })
247 .then(() => this.createSocketToApp(retryAttempt));
248 }
249
250 public stop() {
251 if (this.socketToApp) {
252 this.socketToApp.removeAllListeners();
253 this.socketToApp.close();
254 }
255
256 if (this.singleLifetimeWorker) {
257 this.singleLifetimeWorker.stop();
258 }
259 }
260
261 public downloadAndPatchDebuggerWorker(): Promise<void> {
262 let scriptToRunPath = path.resolve(this.sourcesStoragePath, ScriptImporter.DEBUGGER_WORKER_FILENAME);
263 return this.scriptImporter.downloadDebuggerWorker(this.sourcesStoragePath, this.projectRootPath, this.debuggerWorkerUrlPath)
264 .then(() => this.nodeFileSystem.readFile(scriptToRunPath, "utf8"))
265 .then((workerContent: string) => {
266 const isHaulProject = ReactNativeProjectHelper.isHaulProject(this.projectRootPath);
267 // Add our customizations to debugger worker to get it working smoothly
268 // in Node env and polyfill WebWorkers API over Node's IPC.
269 const modifiedDebuggeeContent = [
270 MultipleLifetimesAppWorker.WORKER_BOOTSTRAP,
271 MultipleLifetimesAppWorker.CONSOLE_TRACE_PATCH,
272 MultipleLifetimesAppWorker.PROCESS_TO_STRING_PATCH,
273 isHaulProject ? MultipleLifetimesAppWorker.FETCH_STUB : null,
274 workerContent,
275 MultipleLifetimesAppWorker.WORKER_DONE,
276 ].join("\n");
277 return this.nodeFileSystem.writeFile(scriptToRunPath, modifiedDebuggeeContent);
278 });
279 }
280
281 public showDevMenuCommand(): void {
282 if (this.singleLifetimeWorker) {
283 this.singleLifetimeWorker.postMessage({
284 method: "vscode_showDevMenu",
285 });
286 }
287 }
288
289 public reloadAppCommand(): void {
290 if (this.singleLifetimeWorker) {
291 this.singleLifetimeWorker.postMessage({
292 method: "vscode_reloadApp",
293 });
294 }
295 }
296
297 private startNewWorkerLifetime(): Promise<void> {
298 this.singleLifetimeWorker = new ForkedAppWorker(this.packagerAddress, this.packagerPort, this.sourcesStoragePath, this.projectRootPath,
299 (message) => {
300 this.sendMessageToApp(message);
301 },
302 this.packagerRemoteRoot, this.packagerLocalRoot);
303 logger.verbose("A new app worker lifetime was created.");
304 return this.singleLifetimeWorker.start()
305 .then(startedEvent => {
306 this.emit("connected", startedEvent);
307 });
308 }
309
310 private createSocketToApp(retryAttempt: boolean = false): Promise<void> {
311 return new Promise((resolve, reject) => {
312 this.socketToApp = this.webSocketConstructor(this.debuggerProxyUrl());
313 this.socketToApp.on("open", () => {
314 this.onSocketOpened();
315 });
316 this.socketToApp.on("close",
317 () => {
318 this.executionLimiter.execute("onSocketClose.msg", /*limitInSeconds*/ 10, () => {
319 /*
320 * It is not the best idea to compare with the message, but this is the only thing React Native gives that is unique when
321 * it closes the socket because it already has a connection to a debugger.
322 * https://github.com/facebook/react-native/blob/588f01e9982775f0699c7bfd56623d4ed3949810/local-cli/server/util/webSocketProxy.js#L38
323 */
324 let msgKey = "_closeMessage";
325 if (this.socketToApp[msgKey] === "Another debugger is already connected") {
326 reject(ErrorHelper.getInternalError(InternalErrorCode.AnotherDebuggerConnectedToPackager));
327 }
328 logger.log(localize("DisconnectedFromThePackagerToReactNative", "Disconnected from the Proxy (Packager) to the React Native application. Retrying reconnection soon..."));
329 });
330 setTimeout(() => {
331 this.start(true /* retryAttempt */);
332 }, 100);
333 });
334 this.socketToApp.on("message",
335 (message: any) => this.onMessage(message));
336 this.socketToApp.on("error",
337 (error: Error) => {
338 if (retryAttempt) {
339 printDebuggingError(ErrorHelper.getInternalError(InternalErrorCode.ReconnectionToPackagerFailedCheckForErrorsOrRestartReactNative), error);
340 }
341
342 reject(error);
343 });
344
345 // In an attempt to catch failures in starting the packager on first attempt,
346 // wait for 300 ms before resolving the promise
347 PromiseUtil.delay(300).then(() => resolve());
348 });
349 }
350
351 private debuggerProxyUrl() {
352 return `ws://${this.packagerAddress}:${this.packagerPort}/debugger-proxy?role=debugger&name=vscode`;
353 }
354
355 private onSocketOpened() {
356 this.executionLimiter.execute("onSocketOpened.msg", /*limitInSeconds*/ 10, () =>
357 logger.log(localize("EstablishedConnectionWithPackagerToReactNativeApp", "Established a connection with the Proxy (Packager) to the React Native application")));
358 }
359
360 private killWorker() {
361 if (!this.singleLifetimeWorker) return;
362 this.singleLifetimeWorker.stop();
363 this.singleLifetimeWorker = null;
364 }
365
366 private onMessage(message: string) {
367 try {
368 logger.verbose("From RN APP: " + message);
369 let object = <RNAppMessage>JSON.parse(message);
370 if (object.method === "prepareJSRuntime") {
371 // In RN 0.40 Android runtime doesn't seem to be sending "$disconnected" event
372 // when user reloads an app, hence we need to try to kill it here either.
373 this.killWorker();
374 // The MultipleLifetimesAppWorker will handle prepareJSRuntime aka create new lifetime
375 this.gotPrepareJSRuntime(object);
376 } else if (object.method === "$disconnected") {
377 // We need to shutdown the current app worker, and create a new lifetime
378 this.killWorker();
379 } else if (object.method) {
380 // All the other messages are handled by the single lifetime worker
381 if (this.singleLifetimeWorker) {
382 this.singleLifetimeWorker.postMessage(object);
383 }
384 } else {
385 // Message doesn't have a method. Ignore it. This is an info message instead of warn because it's normal and expected
386 logger.verbose(`The react-native app sent a message without specifying a method: ${message}`);
387 }
388 } catch (exception) {
389 printDebuggingError(ErrorHelper.getInternalError(InternalErrorCode.FailedToProcessMessageFromReactNativeApp, message), exception);
390 }
391 }
392
393 private gotPrepareJSRuntime(message: any): void {
394 // Create the sandbox, and replay that we finished processing the message
395 this.startNewWorkerLifetime().then(() => {
396 this.sendMessageToApp({ replyID: parseInt(message.id, 10) });
397 }, error => printDebuggingError(ErrorHelper.getInternalError(InternalErrorCode.FailedToPrepareJSRuntimeEnvironment, message), error));
398 }
399
400 private sendMessageToApp(message: any): void {
401 let stringified: string = "";
402 try {
403 stringified = JSON.stringify(message);
404 logger.verbose(`To RN APP: ${stringified}`);
405 this.socketToApp.send(stringified);
406 } catch (exception) {
407 let messageToShow = stringified || ("" + message); // Try to show the stringified version, but show the toString if unavailable
408 printDebuggingError(ErrorHelper.getInternalError(InternalErrorCode.FailedToSendMessageToTheReactNativeApp, messageToShow), exception);
409 }
410 }
411}
412