microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
8f87e13531e1fc4465733c748fd76d4ae5710eaa

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/debugger/appWorker.ts

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