From a2addbd932365408bb8edc2334e7c4f090a14339 Mon Sep 17 00:00:00 2001 From: Lukas Piatkowski Date: Thu, 16 Feb 2017 04:20:32 -0800 Subject: [PATCH] BREAKING: Change the js1 - packager - RN App protocol to version 2 Reviewed By: cwdick Differential Revision: D4551991 fbshipit-source-id: 395c38ee5c71ddc24d8743e7ec90cc89de087503 --- React/Modules/RCTDevMenu.mm | 18 ++- .../react/devsupport/DevServerHelper.java | 2 +- .../packagerconnection/JSPackagerClient.java | 32 +++-- .../JSPackagerClientTest.java | 99 ++++++++----- local-cli/server/util/jsPackagerClient.js | 115 +++++++++++++++ local-cli/server/util/messageSocket.js | 133 ++++++++++++++++-- 6 files changed, 326 insertions(+), 73 deletions(-) create mode 100644 local-cli/server/util/jsPackagerClient.js diff --git a/React/Modules/RCTDevMenu.mm b/React/Modules/RCTDevMenu.mm index 534a39499..bb73f2a47 100644 --- a/React/Modules/RCTDevMenu.mm +++ b/React/Modules/RCTDevMenu.mm @@ -269,7 +269,7 @@ RCT_EXPORT_MODULE() if (!port) { port = @8081; // Packager default port } - return [NSURL URLWithString:[NSString stringWithFormat:@"%@://%@:%@/message?role=shell", scheme, host, port]]; + return [NSURL URLWithString:[NSString stringWithFormat:@"%@://%@:%@/message?role=ios-rn-rctdevmenu", scheme, host, port]]; } // TODO: Move non-UI logic into separate RCTDevSettings module @@ -310,26 +310,24 @@ RCT_EXPORT_MODULE() - (BOOL)isSupportedVersion:(NSNumber *)version { - NSArray *const kSupportedVersions = @[ @1 ]; + NSArray *const kSupportedVersions = @[ @2 ]; return [kSupportedVersions containsObject:version]; } - (void)didReceiveWebSocketMessage:(NSDictionary *)message { if ([self isSupportedVersion:message[@"version"]]) { - [self processTarget:message[@"target"] action:message[@"action"] options:message[@"options"]]; + [self processMethod:message[@"method"] params:message[@"params"]]; } } -- (void)processTarget:(NSString *)target action:(NSString *)action options:(NSDictionary *)options +- (void)processMethod:(NSString *)method params:(NSDictionary *)params { - if ([target isEqualToString:@"bridge"]) { - if ([action isEqualToString:@"reload"]) { - if ([options[@"debug"] boolValue]) { - _bridge.executorClass = objc_lookUpClass("RCTWebSocketExecutor"); - } - [_bridge reload]; + if ([method isEqualToString:@"reload"]) { + if (![params isEqual:[NSNull null]] && [params[@"debug"] boolValue]) { + _bridge.executorClass = objc_lookUpClass("RCTWebSocketExecutor"); } + [_bridge reload]; } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java index 4f4358ac1..69f35566b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java @@ -65,7 +65,7 @@ public class DevServerHelper { private static final String ONCHANGE_ENDPOINT_URL_FORMAT = "http://%s/onchange"; private static final String WEBSOCKET_PROXY_URL_FORMAT = "ws://%s/debugger-proxy?role=client"; - private static final String PACKAGER_CONNECTION_URL_FORMAT = "ws://%s/message?role=shell"; + private static final String PACKAGER_CONNECTION_URL_FORMAT = "ws://%s/message?role=android-rn-devserverhelper"; private static final String PACKAGER_STATUS_URL_FORMAT = "http://%s/status"; private static final String HEAP_CAPTURE_UPLOAD_URL_FORMAT = "http://%s/jscheapcaptureupload"; private static final String INSPECTOR_DEVICE_URL_FORMAT = "http://%s/inspector/device?name=%s"; diff --git a/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/JSPackagerClient.java b/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/JSPackagerClient.java index 52ea39d47..5170d46d0 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/JSPackagerClient.java +++ b/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/JSPackagerClient.java @@ -25,7 +25,7 @@ import org.json.JSONObject; */ final public class JSPackagerClient implements ReconnectingWebSocket.MessageCallback { private static final String TAG = JSPackagerClient.class.getSimpleName(); - private static final int PROTOCOL_VERSION = 1; + private static final int PROTOCOL_VERSION = 2; public class Responder { private Object mId; @@ -38,8 +38,8 @@ final public class JSPackagerClient implements ReconnectingWebSocket.MessageCall try { JSONObject message = new JSONObject(); message.put("version", PROTOCOL_VERSION); - message.put("target", "profiler"); - message.put("action", result); + message.put("id", mId); + message.put("result", result); mWebSocket.sendMessage(RequestBody.create(WebSocket.TEXT, message.toString())); } catch (Exception e) { FLog.e(TAG, "Responding failed", e); @@ -109,8 +109,9 @@ final public class JSPackagerClient implements ReconnectingWebSocket.MessageCall JSONObject message = new JSONObject(response.string()); int version = message.optInt("version"); - String target = message.optString("target"); - String action = message.optString("action"); + String method = message.optString("method"); + Object id = message.opt("id"); + Object params = message.opt("params"); if (version != PROTOCOL_VERSION) { FLog.e( @@ -119,20 +120,21 @@ final public class JSPackagerClient implements ReconnectingWebSocket.MessageCall return; } - if (!"bridge".equals(target)) { + if (method == null) { + abortOnMessage(id, "No method provided"); return; } - RequestHandler handler = mRequestHandlers.get(action); + RequestHandler handler = mRequestHandlers.get(method); if (handler == null) { - FLog.e(TAG, "No request handler for action: " + action); + abortOnMessage(id, "No request handler for method: " + method); return; } - if (!"pokeSamplingProfiler".equals(action)) { - handler.onNotification(null); + if (id == null) { + handler.onNotification(params); } else { - handler.onRequest(null, new Responder("profiler")); + handler.onRequest(params, new Responder(id)); } } catch (Exception e) { FLog.e(TAG, "Handling the message failed", e); @@ -140,4 +142,12 @@ final public class JSPackagerClient implements ReconnectingWebSocket.MessageCall response.close(); } } + + private void abortOnMessage(Object id, String reason) { + if (id != null) { + (new Responder(id)).error(reason); + } + + FLog.e(TAG, "Handling the message failed with reason: " + reason); + } } diff --git a/ReactAndroid/src/test/java/com/facebook/react/packagerconnection/JSPackagerClientTest.java b/ReactAndroid/src/test/java/com/facebook/react/packagerconnection/JSPackagerClientTest.java index 412861655..6c67780e0 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/packagerconnection/JSPackagerClientTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/packagerconnection/JSPackagerClientTest.java @@ -33,93 +33,114 @@ public class JSPackagerClientTest { } @Test - public void test_onMessage_ShouldTriggerCallback() throws IOException { + public void test_onMessage_ShouldTriggerNotification() throws IOException { JSPackagerClient.RequestHandler handler = mock(JSPackagerClient.RequestHandler.class); - final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("actionValue", handler)); + final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("methodValue", handler)); WebSocket webSocket = mock(WebSocket.class); client.onMessage( ResponseBody.create( WebSocket.TEXT, - "{\"version\": 1, \"target\": \"bridge\", \"action\": \"actionValue\"}")); - verify(handler).onNotification(any()); + "{\"version\": 2, \"method\": \"methodValue\", \"params\": \"paramsValue\"}")); + verify(handler).onNotification(eq("paramsValue")); + verify(handler, never()).onRequest(any(), any(JSPackagerClient.Responder.class)); + } + + @Test + public void test_onMessage_ShouldTriggerRequest() throws IOException { + JSPackagerClient.RequestHandler handler = mock(JSPackagerClient.RequestHandler.class); + final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("methodValue", handler)); + WebSocket webSocket = mock(WebSocket.class); + + client.onMessage( + ResponseBody.create( + WebSocket.TEXT, + "{\"version\": 2, \"id\": \"idValue\", \"method\": \"methodValue\", \"params\": \"paramsValue\"}")); + verify(handler, never()).onNotification(any()); + verify(handler).onRequest(eq("paramsValue"), any(JSPackagerClient.Responder.class)); + } + + @Test + public void test_onMessage_WithoutParams_ShouldTriggerNotification() throws IOException { + JSPackagerClient.RequestHandler handler = mock(JSPackagerClient.RequestHandler.class); + final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("methodValue", handler)); + WebSocket webSocket = mock(WebSocket.class); + + client.onMessage( + ResponseBody.create( + WebSocket.TEXT, + "{\"version\": 2, \"method\": \"methodValue\"}")); + verify(handler).onNotification(eq(null)); + verify(handler, never()).onRequest(any(), any(JSPackagerClient.Responder.class)); } @Test public void test_onMessage_WithInvalidContentType_ShouldNotTriggerCallback() throws IOException { JSPackagerClient.RequestHandler handler = mock(JSPackagerClient.RequestHandler.class); - final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("actionValue", handler)); + final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("methodValue", handler)); WebSocket webSocket = mock(WebSocket.class); client.onMessage( ResponseBody.create( WebSocket.BINARY, - "{\"version\": 1, \"target\": \"bridge\", \"action\": \"actionValue\"}")); + "{\"version\": 2, \"method\": \"methodValue\"}")); verify(handler, never()).onNotification(any()); + verify(handler, never()).onRequest(any(), any(JSPackagerClient.Responder.class)); } @Test - public void test_onMessage_WithoutTarget_ShouldNotTriggerCallback() throws IOException { + public void test_onMessage_WithoutMethod_ShouldNotTriggerCallback() throws IOException { JSPackagerClient.RequestHandler handler = mock(JSPackagerClient.RequestHandler.class); - final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("actionValue", handler)); + final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("methodValue", handler)); WebSocket webSocket = mock(WebSocket.class); client.onMessage( ResponseBody.create( WebSocket.TEXT, - "{\"version\": 1, \"action\": \"actionValue\"}")); - verify(handler, never()).onNotification(any()); - } - - @Test - public void test_onMessage_With_Null_Target_ShouldNotTriggerCallback() throws IOException { - JSPackagerClient.RequestHandler handler = mock(JSPackagerClient.RequestHandler.class); - final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("actionValue", handler)); - WebSocket webSocket = mock(WebSocket.class); - - client.onMessage( - ResponseBody.create( - WebSocket.TEXT, - "{\"version\": 1, \"target\": null, \"action\": \"actionValue\"}")); - verify(handler, never()).onNotification(any()); - } - - @Test - public void test_onMessage_WithoutAction_ShouldNotTriggerCallback() throws IOException { - JSPackagerClient.RequestHandler handler = mock(JSPackagerClient.RequestHandler.class); - final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("actionValue", handler)); - WebSocket webSocket = mock(WebSocket.class); - - client.onMessage( - ResponseBody.create( - WebSocket.TEXT, - "{\"version\": 1, \"target\": \"bridge\"}")); + "{\"version\": 2}")); verify(handler, never()).onNotification(any()); + verify(handler, never()).onRequest(any(), any(JSPackagerClient.Responder.class)); } @Test public void test_onMessage_With_Null_Action_ShouldNotTriggerCallback() throws IOException { JSPackagerClient.RequestHandler handler = mock(JSPackagerClient.RequestHandler.class); - final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("actionValue", handler)); + final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("methodValue", handler)); WebSocket webSocket = mock(WebSocket.class); client.onMessage( ResponseBody.create( WebSocket.TEXT, - "{\"version\": 1, \"target\": \"bridge\", \"action\": null}")); + "{\"version\": 2, \"method\": null}")); verify(handler, never()).onNotification(any()); + verify(handler, never()).onRequest(any(), any(JSPackagerClient.Responder.class)); + } + + @Test + public void test_onMessage_WithInvalidMethod_ShouldNotTriggerCallback() throws IOException { + JSPackagerClient.RequestHandler handler = mock(JSPackagerClient.RequestHandler.class); + final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("methodValue", handler)); + WebSocket webSocket = mock(WebSocket.class); + + client.onMessage( + ResponseBody.create( + WebSocket.BINARY, + "{\"version\": 2, \"method\": \"methodValue2\"}")); + verify(handler, never()).onNotification(any()); + verify(handler, never()).onRequest(any(), any(JSPackagerClient.Responder.class)); } @Test public void test_onMessage_WrongVersion_ShouldNotTriggerCallback() throws IOException { JSPackagerClient.RequestHandler handler = mock(JSPackagerClient.RequestHandler.class); - final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("actionValue", handler)); + final JSPackagerClient client = new JSPackagerClient("ws://not_needed", createRH("methodValue", handler)); WebSocket webSocket = mock(WebSocket.class); client.onMessage( ResponseBody.create( WebSocket.TEXT, - "{\"version\": 2, \"target\": \"bridge\", \"action\": \"actionValue\"}")); + "{\"version\": 1, \"method\": \"methodValue\"}")); verify(handler, never()).onNotification(any()); + verify(handler, never()).onRequest(any(), any(JSPackagerClient.Responder.class)); } } diff --git a/local-cli/server/util/jsPackagerClient.js b/local-cli/server/util/jsPackagerClient.js new file mode 100644 index 000000000..582e74416 --- /dev/null +++ b/local-cli/server/util/jsPackagerClient.js @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +'use strict'; + +const WebSocket = require('ws'); + +const parseMessage = require('./messageSocket').parseMessage; + +const PROTOCOL_VERSION = 2; +const TARGET_SERVER = 'server'; + +function getMessageId() { + return `${Date.now()}:${Math.random()}`; +} + +class JsPackagerClient { + constructor(url) { + this.ws = new WebSocket(url); + this.msgCallbacks = new Map(); + + this.openPromise = new Promise((resolve, reject) => { + this.ws.on('error', error => reject(error)); + this.ws.on('open', resolve); + }); + + this.ws.on('message', (data, flags) => { + const message = parseMessage(data, flags.binary); + const msgCallback = this.msgCallbacks.get(message.id); + if (message === undefined || message.id === undefined) { + // gracefully ignore wrong messages or broadcasts + } else if (msgCallback === undefined) { + console.warn(`Response with non-existing message id: '${message.id}'`); + } else { + if (message.error === undefined) { + msgCallback.resolve(message.result); + } else { + msgCallback.reject(message.error); + } + } + }); + } + + sendRequest(method, target, params) { + return this.openPromise.then(() => new Promise((resolve, reject) => { + const messageId = getMessageId(); + this.msgCallbacks.set(messageId, {resolve: resolve, reject: reject}); + this.ws.send( + JSON.stringify({ + version: PROTOCOL_VERSION, + target: target, + method: method, + id: messageId, + params: params, + }), + error => { + if (error !== undefined) { + this.msgCallbacks.delete(messageId); + reject(error); + } + }); + })); + } + + sendNotification(method, target, params) { + return this.openPromise.then(() => new Promise((resolve, reject) => { + this.ws.send( + JSON.stringify({ + version: PROTOCOL_VERSION, + target: target, + method: method, + params: params, + }), + error => { + if (error !== undefined) { + reject(error); + } else { + resolve(); + } + }); + })); + } + + sendBroadcast(method, params) { + return this.sendNotification(method, undefined, params); + } + + getPeers() { + return new Promise((resolve, reject) => { + this.sendRequest('getpeers', TARGET_SERVER, undefined).then( + response => { + if (!response instanceof Map) { + reject('Results received from server are of wrong format:\n' + + JSON.stringify(response)); + } else { + resolve(response); + } + }, + reject); + }); + } + + getId() { + return this.sendRequest('getid', TARGET_SERVER, undefined); + } +} + +module.exports = JsPackagerClient; diff --git a/local-cli/server/util/messageSocket.js b/local-cli/server/util/messageSocket.js index 48a61aad6..45f9b0660 100644 --- a/local-cli/server/util/messageSocket.js +++ b/local-cli/server/util/messageSocket.js @@ -8,8 +8,9 @@ */ 'use strict'; +const url = require('url'); const WebSocketServer = require('ws').Server; -const PROTOCOL_VERSION = 1; +const PROTOCOL_VERSION = 2; function parseMessage(data, binary) { if (binary) { @@ -29,6 +30,30 @@ function parseMessage(data, binary) { return undefined; } +function isBroadcast(message) { + return ( + typeof message.method === 'string' && + message.id === undefined && + message.target === undefined + ); +} + +function isRequest(message) { + return ( + typeof message.method === 'string' && + typeof message.target === 'string'); +} + +function isResponse(message) { + return ( + typeof message.id === 'object' && + typeof message.id.requestId !== undefined && + typeof message.id.clientId === 'string' && ( + message.result !== undefined || + message.error !== undefined + )); +} + function attachToServer(server, path) { const wss = new WebSocketServer({ server: server, @@ -37,11 +62,19 @@ function attachToServer(server, path) { const clients = new Map(); let nextClientId = 0; + function getClientWs(clientId) { + const clientWs = clients.get(clientId); + if (clientWs === undefined) { + throw `could not find id "${clientId}" while forwarding request`; + } + return clientWs; + } + function handleSendBroadcast(broadcasterId, message) { const forwarded = { version: PROTOCOL_VERSION, - target: message.target, - action: message.action, + method: message.method, + params: message.params, }; for (const [otherId, otherWs] of clients) { if (otherId !== broadcasterId) { @@ -58,14 +91,78 @@ function attachToServer(server, path) { wss.on('connection', function(clientWs) { const clientId = `client#${nextClientId++}`; - function handleCatchedError(message, error) { + function handleCaughtError(message, error) { const errorMessage = { + id: message.id, + method: message.method, target: message.target, - action: message.action === undefined ? 'undefined' : 'defined', + error: message.error === undefined ? 'undefined' : 'defined', + params: message.params === undefined ? 'undefined' : 'defined', + result: message.result === undefined ? 'undefined' : 'defined', }; - console.error( - `Handling message from ${clientId} failed with:\n${error}\n` + - `message:\n${JSON.stringify(errorMessage)}`); + + if (message.id === undefined) { + console.error( + `Handling message from ${clientId} failed with:\n${error}\n` + + `message:\n${JSON.stringify(errorMessage)}`); + } else { + try { + clientWs.send(JSON.stringify({ + version: PROTOCOL_VERSION, + error: error, + id: message.id, + })); + } catch (e) { + console.error(`Failed to reply to ${clientId} with error:\n${error}` + + `\nmessage:\n${JSON.stringify(errorMessage)}` + + `\ndue to error: ${e.toString()}`); + } + } + } + + function handleServerRequest(message) { + let result = null; + switch (message.method) { + case 'getid': + result = clientId; + break; + case 'getpeers': + result = {}; + clients.forEach((otherWs, otherId) => { + if (clientId !== otherId) { + result[otherId] = url.parse(otherWs.upgradeReq.url).query; + } + }); + break; + default: + throw `unkown method: ${message.method}`; + } + + clientWs.send(JSON.stringify({ + version: PROTOCOL_VERSION, + result: result, + id: message.id + })); + } + + function forwardRequest(message) { + getClientWs(message.target).send(JSON.stringify({ + version: PROTOCOL_VERSION, + method: message.method, + params: message.params, + id: (message.id === undefined + ? undefined + : {requestId: message.id, clientId: clientId}), + })); + } + + function forwardResponse(message) { + getClientWs(message.id.clientId).send(JSON.stringify({ + version: PROTOCOL_VERSION, + result: message.result, + error: message.error, + id: message.id.requestId, + })); } clients.set(clientId, clientWs); @@ -82,16 +179,28 @@ function attachToServer(server, path) { } try { - handleSendBroadcast(clientId, message); + if (isBroadcast(message)) { + handleSendBroadcast(clientId, message); + } else if (isRequest(message)) { + if (message.target === 'server') { + handleServerRequest(message); + } else { + forwardRequest(message); + } + } else if (isResponse(message)) { + forwardResponse(message); + } else { + throw 'Invalid message, did not match the protocol'; + } } catch (e) { - handleCatchedError(message, e.toString()); + handleCaughtError(message, e.toString()); } }; }); return { - broadcast: (target, action) => { - handleSendBroadcast(null, {target: target, action: action}); + broadcast: (method, params) => { + handleSendBroadcast(null, {method: method, params: params}); } }; }