microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
70f7cae4a697f9868d22dfdd08089a2cd2076772

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/debugger/appWorker.ts

333lines · 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 vm from "vm";
5import * as Q from "q";
6import * as path from "path";
7import * as WebSocket from "ws";
8import {ScriptImporter} from "./scriptImporter";
9import {Packager} from "../common/packager";
10import {ErrorHelper} from "../common/error/errorHelper";
11import {Log} from "../common/log/log";
12import {LogLevel} from "../common/log/logHelper";
13import {FileSystem} from "../common/node/fileSystem";
14import {ExecutionsLimiter} from "../common/executionsLimiter";
15
16import Module = require("module");
17
18// This file is a replacement of: https://github.com/facebook/react-native/blob/8d397b4cbc05ad801cfafb421cee39bcfe89711d/local-cli/server/util/debugger.html for Node.JS
19interface DebuggerWorkerSandbox {
20 __debug__: {
21 // To support simulating native functionality when debugging,
22 // we expose a node require function to the app
23 require: (id: string) => any;
24 };
25 __filename: string;
26 __dirname: string;
27 self: DebuggerWorkerSandbox;
28 console: typeof console;
29 importScripts: (url: string) => void;
30 postMessage: (object: any) => void;
31 onmessage: (object: RNAppMessage) => void;
32 postMessageArgument: RNAppMessage; // We use this argument to pass messages to the worker
33}
34
35interface RNAppMessage {
36 method: string;
37 // These objects have also other properties but that we don't currently use
38}
39
40function printDebuggingError(message: string, reason: any) {
41 Log.logWarning(ErrorHelper.getNestedWarning(reason, `${message}. Debugging won't work: Try reloading the JS from inside the app, or Reconnect the VS Code debugger`));
42}
43
44export class SandboxedAppWorker {
45 /** This class will run the RN App logic inside a sandbox. The framework to run the logic is provided by the file
46 * debuggerWorker.js (designed to run on a WebWorker). We load that file inside a sandbox, and then we use the
47 * PROCESS_MESSAGE_INSIDE_SANDBOX script to execute the logic to respond to a message inside the sandbox.
48 * The code inside the debuggerWorker.js will call the global function postMessage to send a reply back to the app,
49 * so we define our custom function there, so we can handle the message. We also provide our own importScript function
50 * to download any script used by debuggerWorker.js
51 */
52 private packagerPort: number;
53 private sourcesStoragePath: string;
54 private debugAdapterPort: number;
55 private postReplyToApp: (message: any) => void;
56
57 private sandbox: DebuggerWorkerSandbox;
58 private sandboxContext: vm.Context;
59 private scriptToReceiveMessageInSandbox: vm.Script;
60
61 private pendingScriptImport = Q(void 0);
62
63 private nodeFileSystem: FileSystem;
64 private scriptImporter: ScriptImporter;
65
66 private static PROCESS_MESSAGE_INSIDE_SANDBOX = "onmessage({ data: postMessageArgument });";
67
68 constructor(packagerPort: number, sourcesStoragePath: string, debugAdapterPort: number, postReplyToApp: (message: any) => void, {
69 nodeFileSystem = new FileSystem(),
70 scriptImporter = new ScriptImporter(packagerPort, sourcesStoragePath),
71 } = {}) {
72 this.packagerPort = packagerPort;
73 this.sourcesStoragePath = sourcesStoragePath;
74 this.debugAdapterPort = debugAdapterPort;
75 this.postReplyToApp = postReplyToApp;
76 this.scriptToReceiveMessageInSandbox = new vm.Script(SandboxedAppWorker.PROCESS_MESSAGE_INSIDE_SANDBOX);
77
78 this.nodeFileSystem = nodeFileSystem;
79 this.scriptImporter = scriptImporter;
80 }
81
82 public start(): Q.Promise<void> {
83
84 // This is a temporary fix for https://github.com/Microsoft/vscode-react-native/issues/340
85 // We need to disable lazy loading for regeneratorRuntime module to avoid infinite recursion
86 let definePropertyInterceptor = "(" + function () {
87 let definePropOriginal = Object.defineProperty;
88 Object.defineProperty = function (object: any, name: string, desc: any) {
89 if (name !== "regeneratorRuntime") {
90 // Behave as usual - as of now we only interested in workaround for regeneratorRuntime
91 return definePropOriginal(object, name, desc);
92 }
93
94 // Patch property descriptor - use static property rather than dynamic
95 // getter to disable react's lazy-loading for regenerator
96 return definePropOriginal(object, name, {
97 enumerable: desc.enumerable,
98 writable: desc.writable,
99 value: desc.get ? desc.get() : desc.value,
100 });
101 };
102 }.toString() + ")()";
103
104 let scriptToRunPath = require.resolve(path.join(this.sourcesStoragePath, ScriptImporter.DEBUGGER_WORKER_FILE_BASENAME));
105 this.initializeSandboxAndContext(scriptToRunPath);
106
107 return Q.when()
108 .then(() => this.runInSandbox("definePropertyInterceptor.js", definePropertyInterceptor))
109 .then(() => this.readFileContents(scriptToRunPath))
110 .then((fileContents: string) =>
111 // On a debugger worker the onmessage variable already exist. We need to declare it before the
112 // javascript file can assign it. We do it in the first line without a new line to not break
113 // the debugging experience of debugging debuggerWorker.js itself (as part of the extension)
114 this.runInSandbox(scriptToRunPath, "var onmessage = null; " + fileContents));
115 }
116
117 public postMessage(object: RNAppMessage): void {
118 this.sandbox.postMessageArgument = object;
119 this.scriptToReceiveMessageInSandbox.runInContext(this.sandboxContext);
120 }
121
122 private initializeSandboxAndContext(scriptToRunPath: string): void {
123 let scriptToRunModule = new Module(scriptToRunPath);
124 scriptToRunModule.paths = Module._nodeModulePaths(path.dirname(scriptToRunPath));
125 // In order for __debug_.require("aNonInternalPackage") to work, we need to initialize where
126 // node searches for packages. We invoke the same method that node does:
127 // https://github.com/nodejs/node/blob/de1dc0ae2eb52842b5c5c974090123a64c3a594c/lib/module.js#L452
128
129 this.sandbox = {
130 __debug__: {
131 require: (filePath: string) => scriptToRunModule.require(filePath),
132 },
133 __filename: scriptToRunPath,
134 __dirname: path.dirname(scriptToRunPath),
135 self: null,
136 console: console,
137 importScripts: (url: string) => this.importScripts(url), // Import script like using <script/>
138 postMessage: (object: any) => this.gotResponseFromDebuggerWorker(object), // Post message back to the UI thread
139 onmessage: null,
140 postMessageArgument: null,
141 };
142 this.sandbox.self = this.sandbox;
143
144 this.sandboxContext = vm.createContext(this.sandbox);
145 }
146
147 private runInSandbox(filename: string, fileContents?: string): Q.Promise<void> {
148 let fileContentsPromise = fileContents
149 ? Q(fileContents)
150 : this.readFileContents(filename);
151
152 return fileContentsPromise.then(contents => {
153 vm.runInContext(contents, this.sandboxContext, filename);
154 });
155 }
156
157 private readFileContents(filename: string) {
158 return this.nodeFileSystem.readFile(filename).then(contents => contents.toString());
159 }
160
161 private importScripts(url: string): void {
162 /* The debuggerWorker.js executes this code:
163 importScripts(message.url);
164 sendReply();
165
166 In the original code importScripts is a sync call. In our code it's async, so we need to mess with sendReply() so we won't
167 actually send the reply back to the application until after importScripts has finished executing. We use
168 this.pendingScriptImport to make the gotResponseFromDebuggerWorker() method hold the reply back, until've finished importing
169 and running the script */
170 let defer = Q.defer<{}>();
171 this.pendingScriptImport = defer.promise;
172
173 // The next line converts to any due to the incorrect typing on node.d.ts of vm.runInThisContext
174 this.scriptImporter.downloadAppScript(url, this.debugAdapterPort)
175 .then(downloadedScript =>
176 this.runInSandbox(downloadedScript.filepath, downloadedScript.contents))
177 .done(() => {
178 // Now we let the reply to the app proceed
179 defer.resolve({});
180 }, reason => {
181 printDebuggingError(`Couldn't import script at <${url}>`, reason);
182 });
183 }
184
185 private gotResponseFromDebuggerWorker(object: any): void {
186 // We might need to hold the response until a script is imported. See comments on this.importScripts()
187 this.pendingScriptImport.done(() =>
188 this.postReplyToApp(object), reason => {
189 printDebuggingError("Unexpected internal error while processing a message from the RN App.", reason);
190 });
191 }
192}
193
194export class MultipleLifetimesAppWorker {
195 /** This class will create a SandboxedAppWorker that will run the RN App logic, and then create a socket
196 * and send the RN App messages to the SandboxedAppWorker. The only RN App message that this class handles
197 * is the prepareJSRuntime, which we reply to the RN App that the sandbox was created successfully.
198 * When the socket closes, we'll create a new SandboxedAppWorker and a new socket pair and discard the old ones.
199 */
200 private packagerPort: number;
201 private sourcesStoragePath: string;
202 private debugAdapterPort: number;
203 private socketToApp: WebSocket;
204 private singleLifetimeWorker: SandboxedAppWorker;
205
206 private sandboxedAppConstructor: (storagePath: string, adapterPort: number, messageFunction: (message: any) => void) => SandboxedAppWorker;
207 private webSocketConstructor: (url: string) => WebSocket;
208
209 private executionLimiter = new ExecutionsLimiter();
210
211 constructor(packagerPort: number, sourcesStoragePath: string, debugAdapterPort: number, {
212 sandboxedAppConstructor = (path: string, port: number, messageFunc: (message: any) => void) =>
213 new SandboxedAppWorker(packagerPort, path, port, messageFunc),
214 webSocketConstructor = (url: string) => new WebSocket(url),
215 } = {}) {
216 this.packagerPort = packagerPort;
217 this.sourcesStoragePath = sourcesStoragePath;
218 this.debugAdapterPort = debugAdapterPort;
219 console.assert(!!this.sourcesStoragePath, "The sourcesStoragePath argument was null or empty");
220
221 this.sandboxedAppConstructor = sandboxedAppConstructor;
222 this.webSocketConstructor = webSocketConstructor;
223 }
224
225 public start(warnOnFailure: boolean = false): Q.Promise<any> {
226 return Packager.isPackagerRunning(Packager.getHostForPort(this.packagerPort))
227 .then(running => {
228 if (running) {
229 return this.createSocketToApp(warnOnFailure);
230 }
231 throw new Error(`Cannot attach to packager. Are you sure there is a packager and it is running in the port ${this.packagerPort}? If your packager is configured to run in another port make sure to add that to the setting.json.`);
232 });
233 }
234
235 private startNewWorkerLifetime(): Q.Promise<void> {
236 this.singleLifetimeWorker = this.sandboxedAppConstructor(this.sourcesStoragePath, this.debugAdapterPort, (message) => {
237 this.sendMessageToApp(message);
238 });
239 Log.logInternalMessage(LogLevel.Info, "A new app worker lifetime was created.");
240 return this.singleLifetimeWorker.start();
241 }
242
243 private createSocketToApp(warnOnFailure: boolean = false): Q.Promise<void> {
244 let deferred = Q.defer<void>();
245 this.socketToApp = this.webSocketConstructor(this.debuggerProxyUrl());
246 this.socketToApp.on("open", () => {
247 this.onSocketOpened();
248 });
249 this.socketToApp.on("close",
250 () => {
251 this.executionLimiter.execute("onSocketClose.msg", /*limitInSeconds*/ 10, () => {
252 /*
253 * It is not the best idea to compare with the message, but this is the only thing React Native gives that is unique when
254 * it closes the socket because it already has a connection to a debugger.
255 * https://github.com/facebook/react-native/blob/588f01e9982775f0699c7bfd56623d4ed3949810/local-cli/server/util/webSocketProxy.js#L38
256 */
257 if (this.socketToApp._closeMessage === "Another debugger is already connected") {
258 deferred.reject(new RangeError("Another debugger is already connected to packager. Please close it before trying to debug with VSCode."));
259 }
260 Log.logMessage("Disconnected from the Proxy (Packager) to the React Native application. Retrying reconnection soon...");
261 });
262 setTimeout(() => {
263 this.start(true /* retryAttempt */);
264 }, 100);
265 });
266 this.socketToApp.on("message",
267 (message: any) => this.onMessage(message));
268 this.socketToApp.on("error",
269 (error: Error) => {
270 if (warnOnFailure) {
271 Log.logWarning(ErrorHelper.getNestedWarning(error,
272 "Reconnection to the proxy (Packager) failed. Please check the output window for Packager errors, if any. If failure persists, please restart the React Native debugger."));
273 }
274
275 deferred.reject(error);
276 });
277
278 // In an attempt to catch failures in starting the packager on first attempt,
279 // wait for 300 ms before resolving the promise
280 Q.delay(300).done(() => deferred.resolve(void 0));
281 return deferred.promise;
282 }
283
284 private debuggerProxyUrl() {
285 return `ws://${Packager.getHostForPort(this.packagerPort)}/debugger-proxy?role=debugger&name=vscode`;
286 }
287
288 private onSocketOpened() {
289 this.executionLimiter.execute("onSocketOpened.msg", /*limitInSeconds*/ 10, () =>
290 Log.logMessage("Established a connection with the Proxy (Packager) to the React Native application"));
291 }
292
293 private onMessage(message: string) {
294 try {
295 Log.logInternalMessage(LogLevel.Trace, "From RN APP: " + message);
296 let object = <RNAppMessage>JSON.parse(message);
297 if (object.method === "prepareJSRuntime") {
298 // The MultipleLifetimesAppWorker will handle prepareJSRuntime aka create new lifetime
299 this.gotPrepareJSRuntime(object);
300 } else if (object.method === "$disconnected") {
301 // We need to shutdown the current app worker, and create a new lifetime
302 this.singleLifetimeWorker = null;
303 } else if (object.method) {
304 // All the other messages are handled by the single lifetime worker
305 this.singleLifetimeWorker.postMessage(object);
306 } else {
307 // Message doesn't have a method. Ignore it. This is an info message instead of warn because it's normal and expected
308 Log.logInternalMessage(LogLevel.Info, "The react-native app sent a message without specifying a method: " + message);
309 }
310 } catch (exception) {
311 printDebuggingError(`Failed to process message from the React Native app. Message:\n${message}`, exception);
312 }
313 }
314
315 private gotPrepareJSRuntime(message: any): void {
316 // Create the sandbox, and replay that we finished processing the message
317 this.startNewWorkerLifetime().done(() => {
318 this.sendMessageToApp({ replyID: parseInt(message.id, 10) });
319 }, error => printDebuggingError(`Failed to prepare the JavaScript runtime environment. Message:\n${message}`, error));
320 }
321
322 private sendMessageToApp(message: any): void {
323 let stringified: string = null;
324 try {
325 stringified = JSON.stringify(message);
326 Log.logInternalMessage(LogLevel.Trace, "To RN APP: " + stringified);
327 this.socketToApp.send(stringified);
328 } catch (exception) {
329 let messageToShow = stringified || ("" + message); // Try to show the stringified version, but show the toString if unavailable
330 printDebuggingError(`Failed to send message to the React Native app. Message:\n${messageToShow}`, exception);
331 }
332 }
333}
334