microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
e38efae14e2dea1c29295979c77fc273570d75a3

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/debugger/appWorker.ts

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