From ee0d7edc0b52967676f8f9dbce8fc721078f8854 Mon Sep 17 00:00:00 2001 From: lilia Date: Thu, 27 Nov 2014 22:03:42 -0800 Subject: [PATCH] WebSocket-Resources / websocket refactor This commit provides the javascript complement to [WebSocket-Resources](https://github.com/WhisperSystems/WebSocket-Resources), allowing us to use a bi-directional request-response framework over websockets. See websocket-resources.js and websocket-resources_test.js for usage details. Along the way I also factored the websocket keepalive and reconnect logic into its own file/wrapper object. --- background.html | 2 + index.html | 2 + js/api.js | 85 ++------------------ js/helpers.js | 17 ++-- js/websocket-resources.js | 134 +++++++++++++++++++++++++++++++ js/websocket.js | 72 +++++++++++++++++ options.html | 2 + test/index.html | 3 + test/websocket-resources_test.js | 98 ++++++++++++++++++++++ 9 files changed, 324 insertions(+), 91 deletions(-) create mode 100644 js/websocket-resources.js create mode 100644 js/websocket.js create mode 100644 test/websocket-resources_test.js diff --git a/background.html b/background.html index 61440858..785268fb 100644 --- a/background.html +++ b/background.html @@ -21,6 +21,8 @@ + + diff --git a/index.html b/index.html index e0957de5..35e9a40a 100644 --- a/index.html +++ b/index.html @@ -131,6 +131,8 @@ + + diff --git a/js/api.js b/js/api.js index ecf14639..766a0a6f 100644 --- a/js/api.js +++ b/js/api.js @@ -303,91 +303,16 @@ window.textsecure.api = function () { var getWebsocket = function(url, auth, reconnectTimeout) { var URL = URL_BASE.replace(/^http/g, 'ws') + url + '/?'; + var params = ''; if (auth) { var user = textsecure.storage.getUnencrypted("number_id"); var password = textsecure.storage.getEncrypted("password"); var params = $.param({ - login: '+' + getString(user).substring(1), - password: getString(password) + login: '+' + user.substring(1), + password: password }); - } else - var params = $.param({}); - - var keepAliveTimer; - var reconnectSemaphore = 0; - var socketWrapper = { onmessage: function() {}, ondisconnect: function() {}, onconnect: function() {} }; - - var connect = function() { - clearTimeout(keepAliveTimer); - reconnectSemaphore++; - if (reconnectSemaphore <= 0) - return; - - if (socket) { socket.close(); } - var socket = new WebSocket(URL+params); - - function resetKeepAliveTimer() { - clearTimeout(keepAliveTimer); - keepAliveTimer = setTimeout(function() { - socket.send(JSON.stringify({type: 1, id: 0})); - resetKeepAliveTimer(); - }, 50000); - }; - - socket.onerror = function(socketEvent) { - console.log('Server is down :('); - reconnectSemaphore--; - setTimeout(function() { connect(); }, reconnectTimeout); - socketWrapper.ondisconnect(); - }; - - socket.onclose = function(socketEvent) { - console.log('Server closed :('); - reconnectSemaphore--; - setTimeout(function() { connect(); }, reconnectTimeout); - socketWrapper.ondisconnect(); - }; - - socket.onopen = function(socketEvent) { - console.log('Connected to server!'); - socketWrapper.onconnect(); - resetKeepAliveTimer(); - }; - - //TODO: wrap onmessage so that we reconnect on missing pong - socket.onmessage = function(response) { - var blob = response.data; - var reader = new FileReader(); - reader.addEventListener("loadend", function() { - // reader.result contains the contents of blob as a typed array - try { - var message = textsecure.protobuf.WebSocketMessage.decode(reader.result); - console.log(message); - if (message.type === textsecure.protobuf.WebSocketMessage.Type.REQUEST ) { - socketWrapper.onmessage(message.request); - } - else { - throw "Got invalid message from server: " + message; - } - - } catch (e) { - console.log('Error parsing server JSON message: ' + response); - return; - } - }); - - reader.readAsArrayBuffer(blob); - - resetKeepAliveTimer(); - }; - - socketWrapper.send = function(msg) { - socket.send(msg); - } - }; - connect(); - - return socketWrapper; + } + return window.textsecure.websocket(URL+params) } self.getMessageWebsocket = function() { diff --git a/js/helpers.js b/js/helpers.js index 95375979..df88dea0 100644 --- a/js/helpers.js +++ b/js/helpers.js @@ -240,19 +240,14 @@ window.textsecure.replay = function() { window.textsecure.subscribeToPush = function(message_callback) { var socket = textsecure.api.getMessageWebsocket(); - socket.onmessage = function(message) { - textsecure.protocol.decryptWebsocketMessage(message.body).then(function(plaintext) { + var resource = new WebSocketResource(socket, function(request) { + // TODO: handle different types of requests. for now we only receive + // PUT /messages + textsecure.protocol.decryptWebsocketMessage(request.body).then(function(plaintext) { var proto = textsecure.protobuf.IncomingPushMessageSignal.decode(plaintext); // After this point, a) decoding errors are not the server's fault, and // b) we should handle them gracefully and tell the user they received an invalid message - console.log("Successfully decoded message with id: " + message.id); - - socket.send( - new textsecure.protobuf.WebSocketMessage({ - response: { id: message.id, message: 'OK', status: 200 }, - type: textsecure.protobuf.WebSocketMessage.Type.RESPONSE - }).encode().toArrayBuffer() - ); + request.respond(200, 'OK'); return textsecure.protocol.handleIncomingPushMessageProto(proto).then(function(decrypted) { // Delivery receipt @@ -348,7 +343,7 @@ window.textsecure.subscribeToPush = function(message_callback) { console.log("Error handling incoming message: "); console.log(e); }); - }; + }); }; window.textsecure.registerSingleDevice = function(number, verificationCode, stepDone) { diff --git a/js/websocket-resources.js b/js/websocket-resources.js new file mode 100644 index 00000000..b034515e --- /dev/null +++ b/js/websocket-resources.js @@ -0,0 +1,134 @@ +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +;(function(){ + 'use strict'; + + /* + * WebSocket-Resources + * + * Create a request-response interface over websockets using the + * WebSocket-Resources sub-protocol[1]. + * + * var client = new WebSocketResource(socket, function(request) { + * request.respond(200, 'OK'); + * }); + * + * client.sendRequest({ + * verb: 'PUT', + * path: '/v1/messages', + * body: '{ some: "json" }', + * success: function(message, status, request) {...}, + * error: function(message, status, request) {...} + * }); + * + * 1. https://github.com/WhisperSystems/WebSocket-Resources + * + */ + + var Request = function(options) { + this.verb = options.verb || options.type; + this.path = options.path || options.url; + this.body = options.body || options.data; + this.success = options.success + this.error = options.error + this.id = options.id; + + if (this.id === undefined) { + var bits = new Uint32Array(2); + window.crypto.getRandomValues(bits); + this.id = dcodeIO.Long.fromBits(bits[0], bits[1], true); + } + }; + + var IncomingWebSocketRequest = function(options) { + var request = new Request(options); + var socket = options.socket; + + this.verb = request.verb; + this.path = request.path; + this.body = request.body; + + this.respond = function(status, message) { + socket.send( + new textsecure.protobuf.WebSocketMessage({ + type: textsecure.protobuf.WebSocketMessage.Type.RESPONSE, + response: { id: request.id, message: message, status: status } + }).encode().toArrayBuffer() + ); + }; + }; + + var outgoing = {}; + var OutgoingWebSocketRequest = function(options, socket) { + var request = new Request(options); + outgoing[request.id] = request; + socket.send( + new textsecure.protobuf.WebSocketMessage({ + type: textsecure.protobuf.WebSocketMessage.Type.REQUEST, + request: { + verb : request.verb, + path : request.path, + body : request.body, + id : request.id + } + }).encode().toArrayBuffer() + ); + }; + + window.WebSocketResource = function(socket, handleRequest) { + this.sendRequest = function(options) { + return new OutgoingWebSocketRequest(options, socket); + }; + + socket.onmessage = function(socketMessage) { + var blob = socketMessage.data; + var reader = new FileReader(); + reader.onload = function() { + var message = textsecure.protobuf.WebSocketMessage.decode(reader.result); + if (message.type === textsecure.protobuf.WebSocketMessage.Type.REQUEST ) { + handleRequest( + new IncomingWebSocketRequest({ + verb : message.request.verb, + path : message.request.path, + body : message.request.body, + id : message.request.id, + socket : socket + }) + ); + } + else if (message.type === textsecure.protobuf.WebSocketMessage.Type.RESPONSE ) { + var response = message.response; + var request = outgoing[response.id]; + if (request) { + request.response = response; + var callback = request.error; + if (response.status >= 200 && response.status < 300) { + callback = request.success; + } + + if (typeof callback === 'function') { + callback(response.message, response.status, request); + } + } else { + throw 'Received response for unknown request ' + message.response.id; + } + } + }; + reader.readAsArrayBuffer(blob); + }; + }; + +}()); diff --git a/js/websocket.js b/js/websocket.js new file mode 100644 index 00000000..6cc6bea4 --- /dev/null +++ b/js/websocket.js @@ -0,0 +1,72 @@ +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +;(function(){ + 'use strict'; + + /* + * var socket = textsecure.websocket(url); + * + * Returns an adamantium-reinforced super socket, capable of sending + * app-level keep alives and automatically reconnecting. + * + */ + + window.textsecure.websocket = function (url) { + var socketWrapper = { onmessage: function() {}, ondisconnect: function() {} }; + var socket; + var keepAliveTimer; + var reconnectSemaphore = 0; + var reconnectTimeout = 1000; + + function resetKeepAliveTimer() { + clearTimeout(keepAliveTimer); + keepAliveTimer = setTimeout(function() { + socket.send(JSON.stringify({type: 1, id: 0})); + resetKeepAliveTimer(); + }, 50000); + }; + + function reconnect(e) { + reconnectSemaphore--; + setTimeout(connect, reconnectTimeout); + socketWrapper.ondisconnect(e); + }; + + var connect = function() { + clearTimeout(keepAliveTimer); + if (++reconnectSemaphore <= 0) { return; } + + if (socket) { socket.close(); } + socket = new WebSocket(url); + + socket.onerror = reconnect; + socket.onclose = reconnect; + socket.onopen = resetKeepAliveTimer; + + socket.onmessage = function(response) { + socketWrapper.onmessage(response); + resetKeepAliveTimer(); + }; + + socketWrapper.send = function(msg) { + socket.send(msg); + } + }; + + connect(); + return socketWrapper; + }; +})(); diff --git a/options.html b/options.html index e25dba54..643d962f 100644 --- a/options.html +++ b/options.html @@ -100,6 +100,8 @@ + + diff --git a/test/index.html b/test/index.html index 715a2ae5..ff6638ce 100644 --- a/test/index.html +++ b/test/index.html @@ -126,6 +126,8 @@ + + @@ -165,6 +167,7 @@ + diff --git a/test/websocket-resources_test.js b/test/websocket-resources_test.js new file mode 100644 index 00000000..509a22ce --- /dev/null +++ b/test/websocket-resources_test.js @@ -0,0 +1,98 @@ +/* vim: ts=4:sw=4 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +;(function() { + 'use strict'; + + describe('WebSocket-Resource', function() { + it('receives requests and sends responses', function(done) { + // mock socket + var request_id = '1'; + var socket = { + send: function(data) { + var message = textsecure.protobuf.WebSocketMessage.decode(data); + assert.strictEqual(message.type, textsecure.protobuf.WebSocketMessage.Type.RESPONSE); + assert.strictEqual(message.response.message, 'OK'); + assert.strictEqual(message.response.status, 200); + assert.strictEqual(message.response.id.toString(), request_id); + done(); + } + }; + + // actual test + var resource = new WebSocketResource(socket, function (request) { + assert.strictEqual(request.verb, 'PUT'); + assert.strictEqual(request.path, '/some/path'); + assertEqualArrayBuffers(request.body.toArrayBuffer(), new Uint8Array([1,2,3]).buffer); + request.respond(200, 'OK'); + }); + + // mock socket request + socket.onmessage({ + data: new Blob([ + new textsecure.protobuf.WebSocketMessage({ + type: textsecure.protobuf.WebSocketMessage.Type.REQUEST, + request: { + id: request_id, + verb: 'PUT', + path: '/some/path', + body: new Uint8Array([1,2,3]).buffer + } + }).encode().toArrayBuffer() + ]) + }); + }); + + it('sends requests and receives responses', function(done) { + // mock socket and request handler + var request_id; + var socket = { + send: function(data) { + var message = textsecure.protobuf.WebSocketMessage.decode(data); + assert.strictEqual(message.type, textsecure.protobuf.WebSocketMessage.Type.REQUEST); + assert.strictEqual(message.request.verb, 'PUT'); + assert.strictEqual(message.request.path, '/some/path'); + assertEqualArrayBuffers(message.request.body.toArrayBuffer(), new Uint8Array([1,2,3]).buffer); + request_id = message.request.id; + } + }; + + // actual test + var resource = new WebSocketResource(socket, function() {}); + resource.sendRequest({ + verb: 'PUT', + path: '/some/path', + body: new Uint8Array([1,2,3]).buffer, + error: done, + success: function(message, status, request) { + assert.strictEqual(message, 'OK'); + assert.strictEqual(status, 200); + done(); + } + }); + + // mock socket response + socket.onmessage({ + data: new Blob([ + new textsecure.protobuf.WebSocketMessage({ + type: textsecure.protobuf.WebSocketMessage.Type.RESPONSE, + response: { id: request_id, message: 'OK', status: 200 } + }).encode().toArrayBuffer() + ]) + }); + }); + }); +}());