diff --git a/.gitignore b/.gitignore index 857bc3f..8736ba0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ *.pyc .coverage.* TODO +node_modules # IDE and Tooling files .idea/* diff --git a/.travis.yml b/.travis.yml index f38f5a8..581d0a8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,11 +17,13 @@ cache: - $HOME/.cache/pip/wheels install: + - nvm install 7 - pip install -U pip wheel setuptools - pip install $DJANGO -e .[tests] - pip freeze script: - python runtests.py + - cd js_client && npm install --progress=false && npm test && cd .. - flake8 - isort --check-only --recursive channels diff --git a/Makefile b/Makefile index 1a1f55e..612ab19 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,9 @@ all: +build_assets: + cd js_client && npm run browserify && cd .. + release: ifndef version $(error Please supply a version) @@ -14,3 +17,4 @@ endif git push git push --tags python setup.py sdist bdist_wheel upload + cd js_client && npm publish && cd .. diff --git a/channels/static/channels/js/websocketbridge.js b/channels/static/channels/js/websocketbridge.js new file mode 100644 index 0000000..5c647e6 --- /dev/null +++ b/channels/static/channels/js/websocketbridge.js @@ -0,0 +1,395 @@ +/*! + * Do not edit!. This file is autogenerated by running `npm run browserify`. + */ +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.channels = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o config.maxReconnectionDelay) + ? config.maxReconnectionDelay + : newDelay; +}; +var LEVEL_0_EVENTS = ['onopen', 'onclose', 'onmessage', 'onerror']; +var reassignEventListeners = function (ws, oldWs, listeners) { + Object.keys(listeners).forEach(function (type) { + listeners[type].forEach(function (_a) { + var listener = _a[0], options = _a[1]; + ws.addEventListener(type, listener, options); + }); + }); + if (oldWs) { + LEVEL_0_EVENTS.forEach(function (name) { ws[name] = oldWs[name]; }); + } +}; +var ReconnectingWebsocket = function (url, protocols, options) { + var _this = this; + if (options === void 0) { options = {}; } + var ws; + var connectingTimeout; + var reconnectDelay = 0; + var retriesCount = 0; + var shouldRetry = true; + var savedOnClose = null; + var listeners = {}; + // require new to construct + if (!(this instanceof ReconnectingWebsocket)) { + throw new TypeError("Failed to construct 'ReconnectingWebSocket': Please use the 'new' operator"); + } + // Set config. Not using `Object.assign` because of IE11 + var config = getDefaultOptions(); + Object.keys(config) + .filter(function (key) { return options.hasOwnProperty(key); }) + .forEach(function (key) { return config[key] = options[key]; }); + if (!isWebSocket(config.constructor)) { + throw new TypeError('Invalid WebSocket constructor. Set `options.constructor`'); + } + var log = config.debug ? function () { + var params = []; + for (var _i = 0; _i < arguments.length; _i++) { + params[_i - 0] = arguments[_i]; + } + return console.log.apply(console, ['RWS:'].concat(params)); + } : function () { }; + /** + * Not using dispatchEvent, otherwise we must use a DOM Event object + * Deferred because we want to handle the close event before this + */ + var emitError = function (code, msg) { return setTimeout(function () { + var err = new Error(msg); + err.code = code; + if (Array.isArray(listeners.error)) { + listeners.error.forEach(function (_a) { + var fn = _a[0]; + return fn(err); + }); + } + if (ws.onerror) { + ws.onerror(err); + } + }, 0); }; + var handleClose = function () { + log('close'); + retriesCount++; + log('retries count:', retriesCount); + if (retriesCount > config.maxRetries) { + emitError('EHOSTDOWN', 'Too many failed connection attempts'); + return; + } + if (!reconnectDelay) { + reconnectDelay = initReconnectionDelay(config); + } + else { + reconnectDelay = updateReconnectionDelay(config, reconnectDelay); + } + log('reconnectDelay:', reconnectDelay); + if (shouldRetry) { + setTimeout(connect, reconnectDelay); + } + }; + var connect = function () { + log('connect'); + var oldWs = ws; + ws = new config.constructor(url, protocols); + connectingTimeout = setTimeout(function () { + log('timeout'); + ws.close(); + emitError('ETIMEDOUT', 'Connection timeout'); + }, config.connectionTimeout); + log('bypass properties'); + for (var key in ws) { + // @todo move to constant + if (['addEventListener', 'removeEventListener', 'close', 'send'].indexOf(key) < 0) { + bypassProperty(ws, _this, key); + } + } + ws.addEventListener('open', function () { + clearTimeout(connectingTimeout); + log('open'); + reconnectDelay = initReconnectionDelay(config); + log('reconnectDelay:', reconnectDelay); + retriesCount = 0; + }); + ws.addEventListener('close', handleClose); + reassignEventListeners(ws, oldWs, listeners); + // because when closing with fastClose=true, it is saved and set to null to avoid double calls + ws.onclose = ws.onclose || savedOnClose; + savedOnClose = null; + }; + log('init'); + connect(); + this.close = function (code, reason, _a) { + if (code === void 0) { code = 1000; } + if (reason === void 0) { reason = ''; } + var _b = _a === void 0 ? {} : _a, _c = _b.keepClosed, keepClosed = _c === void 0 ? false : _c, _d = _b.fastClose, fastClose = _d === void 0 ? true : _d, _e = _b.delay, delay = _e === void 0 ? 0 : _e; + if (delay) { + reconnectDelay = delay; + } + shouldRetry = !keepClosed; + ws.close(code, reason); + if (fastClose) { + var fakeCloseEvent_1 = { + code: code, + reason: reason, + wasClean: true, + }; + // execute close listeners soon with a fake closeEvent + // and remove them from the WS instance so they + // don't get fired on the real close. + handleClose(); + ws.removeEventListener('close', handleClose); + // run and remove level2 + if (Array.isArray(listeners.close)) { + listeners.close.forEach(function (_a) { + var listener = _a[0], options = _a[1]; + listener(fakeCloseEvent_1); + ws.removeEventListener('close', listener, options); + }); + } + // run and remove level0 + if (ws.onclose) { + savedOnClose = ws.onclose; + ws.onclose(fakeCloseEvent_1); + ws.onclose = null; + } + } + }; + this.send = function (data) { + ws.send(data); + }; + this.addEventListener = function (type, listener, options) { + if (Array.isArray(listeners[type])) { + if (!listeners[type].some(function (_a) { + var l = _a[0]; + return l === listener; + })) { + listeners[type].push([listener, options]); + } + } + else { + listeners[type] = [[listener, options]]; + } + ws.addEventListener(type, listener, options); + }; + this.removeEventListener = function (type, listener, options) { + if (Array.isArray(listeners[type])) { + listeners[type] = listeners[type].filter(function (_a) { + var l = _a[0]; + return l !== listener; + }); + } + ws.removeEventListener(type, listener, options); + }; +}; +module.exports = ReconnectingWebsocket; + +},{}],2:[function(require,module,exports){ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.WebSocketBridge = undefined; + +var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; + +var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); + +var _reconnectingWebsocket = require('reconnecting-websocket'); + +var _reconnectingWebsocket2 = _interopRequireDefault(_reconnectingWebsocket); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +var noop = function noop() {}; + +/** + * Bridge between Channels and plain javascript. + * + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + * webSocketBridge.listen(function(action, stream) { + * console.log(action, stream); + * }); + */ + +var WebSocketBridge = function () { + function WebSocketBridge(options) { + _classCallCheck(this, WebSocketBridge); + + this._socket = null; + this.streams = {}; + this.default_cb = null; + this.options = _extends({}, { + onopen: noop + }, options); + } + + /** + * Connect to the websocket server + * + * @param {String} [url] The url of the websocket. Defaults to + * `window.location.host` + * @param {String[]|String} [protocols] Optional string or array of protocols. + * @param {Object} options Object of options for [`reconnecting-websocket`](https://github.com/joewalnes/reconnecting-websocket#options-1). + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + */ + + + _createClass(WebSocketBridge, [{ + key: 'connect', + value: function connect(url, protocols, options) { + var _url = void 0; + if (url === undefined) { + // Use wss:// if running on https:// + var scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; + _url = scheme + '://' + window.location.host + '/ws'; + } else { + _url = url; + } + this._socket = new _reconnectingWebsocket2.default(_url, protocols, options); + } + + /** + * Starts listening for messages on the websocket, demultiplexing if necessary. + * + * @param {Function} [cb] Callback to be execute when a message + * arrives. The callback will receive `action` and `stream` parameters + * + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + * webSocketBridge.listen(function(action, stream) { + * console.log(action, stream); + * }); + */ + + }, { + key: 'listen', + value: function listen(cb) { + var _this = this; + + this.default_cb = cb; + this._socket.onmessage = function (event) { + var msg = JSON.parse(event.data); + var action = void 0; + var stream = void 0; + + if (msg.stream !== undefined) { + action = msg.payload; + stream = msg.stream; + var stream_cb = _this.streams[stream]; + stream_cb ? stream_cb(action, stream) : null; + } else { + action = msg; + stream = null; + _this.default_cb ? _this.default_cb(action, stream) : null; + } + }; + + this._socket.onopen = this.options.onopen; + } + + /** + * Adds a 'stream handler' callback. Messages coming from the specified stream + * will call the specified callback. + * + * @param {String} stream The stream name + * @param {Function} cb Callback to be execute when a message + * arrives. The callback will receive `action` and `stream` parameters. + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + * webSocketBridge.listen(); + * webSocketBridge.demultiplex('mystream', function(action, stream) { + * console.log(action, stream); + * }); + * webSocketBridge.demultiplex('myotherstream', function(action, stream) { + * console.info(action, stream); + * }); + */ + + }, { + key: 'demultiplex', + value: function demultiplex(stream, cb) { + this.streams[stream] = cb; + } + + /** + * Sends a message to the reply channel. + * + * @param {Object} msg The message + * + * @example + * webSocketBridge.send({prop1: 'value1', prop2: 'value1'}); + */ + + }, { + key: 'send', + value: function send(msg) { + this._socket.send(JSON.stringify(msg)); + } + + /** + * Returns an object to send messages to a specific stream + * + * @param {String} stream The stream name + * @return {Object} convenience object to send messages to `stream`. + * @example + * webSocketBridge.stream('mystream').send({prop1: 'value1', prop2: 'value1'}) + */ + + }, { + key: 'stream', + value: function stream(_stream) { + var _this2 = this; + + return { + send: function send(action) { + var msg = { + stream: _stream, + payload: action + }; + _this2._socket.send(JSON.stringify(msg)); + } + }; + } + }]); + + return WebSocketBridge; +}(); + +exports.WebSocketBridge = WebSocketBridge; + +},{"reconnecting-websocket":1}]},{},[2])(2) +}); \ No newline at end of file diff --git a/docs/binding.rst b/docs/binding.rst index 517c2ed..64e8ba1 100644 --- a/docs/binding.rst +++ b/docs/binding.rst @@ -136,9 +136,9 @@ Tie that into your routing, and you're ready to go:: Frontend Considerations ----------------------- -You can use the standard Channels WebSocket wrapper **(not yet available)** -to automatically run demultiplexing, and then tie the events you receive into -your frontend framework of choice based on ``action``, ``pk`` and ``data``. +You can use the standard :doc:`Channels WebSocket wrapper ` to +automatically run demultiplexing, and then tie the events you receive into your +frontend framework of choice based on ``action``, ``pk`` and ``data``. .. note:: diff --git a/docs/index.rst b/docs/index.rst index b4c12cd..7135ba9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -49,6 +49,7 @@ Topics generics routing binding + javascript backends delay testing diff --git a/docs/javascript.rst b/docs/javascript.rst new file mode 100644 index 0000000..3d8344f --- /dev/null +++ b/docs/javascript.rst @@ -0,0 +1,45 @@ +Channels WebSocket wrapper +========================== + +Channels ships with a javascript WebSocket wrapper to help you connect to your websocket +and send/receive messages. + +First, you must include the javascript library in your template:: + + {% load staticfiles %} + + {% static "channels/js/websocketbridge.js" %} + +To process messages:: + + const webSocketBridge = new channels.WebSocketBridge(); + webSocketBridge.connect(); + webSocketBridge.listen(function(action, stream) { + console.log(action, stream); + }); + +To send messages, use the `send` method:: + + ``` + webSocketBridge.send({prop1: 'value1', prop2: 'value1'}); + + ``` + +To demultiplex specific streams:: + + webSocketBridge.connect(); + webSocketBridge.listen(); + webSocketBridge.demultiplex('mystream', function(action, stream) { + console.log(action, stream); + }); + webSocketBridge.demultiplex('myotherstream', function(action, stream) { + console.info(action, stream); + }); + + +To send a message to a specific stream:: + + webSocketBridge.stream('mystream').send({prop1: 'value1', prop2: 'value1'}) + +The library is also available as npm module, under the name +`django-channels `_ diff --git a/js_client/.babelrc b/js_client/.babelrc new file mode 100644 index 0000000..6d1bf22 --- /dev/null +++ b/js_client/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + "es2015", + "stage-1", + "react" + ], + "plugins": [ + "transform-object-assign", + ] +} diff --git a/js_client/.eslintrc.js b/js_client/.eslintrc.js new file mode 100644 index 0000000..c06645c --- /dev/null +++ b/js_client/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + "extends": "airbnb", + "plugins": [ + "react" + ], + env: { + jest: true + } +}; diff --git a/js_client/.npmignore b/js_client/.npmignore new file mode 100644 index 0000000..b81236d --- /dev/null +++ b/js_client/.npmignore @@ -0,0 +1,8 @@ +npm-debug.log +node_modules +.*.swp +.lock-* +build +.babelrc +webpack.* +/src/ diff --git a/js_client/README.md b/js_client/README.md new file mode 100644 index 0000000..506cd49 --- /dev/null +++ b/js_client/README.md @@ -0,0 +1,42 @@ +### Usage + +Channels WebSocket wrapper. + +To process messages: + +``` +import { WebSocketBridge } from 'django-channels' + +const webSocketBridge = new WebSocketBridge(); +webSocketBridge.connect(); +webSocketBridge.listen(function(action, stream) { + console.log(action, stream); +}); +``` + +To send messages: + +``` +webSocketBridge.send({prop1: 'value1', prop2: 'value1'}); + +``` + +To demultiplex specific streams: + +``` +const webSocketBridge = new WebSocketBridge(); +webSocketBridge.connect(); +webSocketBridge.listen(); +webSocketBridge.demultiplex('mystream', function(action, stream) { + console.log(action, stream); +}); +webSocketBridge.demultiplex('myotherstream', function(action, stream) { + console.info(action, stream); +}); +``` + +To send a message to a specific stream: + +``` +webSocketBridge.stream('mystream').send({prop1: 'value1', prop2: 'value1'}) +``` diff --git a/js_client/banner.txt b/js_client/banner.txt new file mode 100644 index 0000000..9eb7978 --- /dev/null +++ b/js_client/banner.txt @@ -0,0 +1 @@ +Do not edit!. This file is autogenerated by running `npm run browserify`. \ No newline at end of file diff --git a/js_client/esdoc.json b/js_client/esdoc.json new file mode 100644 index 0000000..157bc8e --- /dev/null +++ b/js_client/esdoc.json @@ -0,0 +1,21 @@ +{ + "source": "./src", + "destination": "./docs", + "undocumentIdentifier": false, + "title": "django-channels", + "experimentalProposal": { + "classProperties": true, + "objectRestSpread": true + }, + "plugins": [ + { + "name": "esdoc-importpath-plugin", + "option": { + "replaces": [ + {"from": "^src/", "to": "lib/"}, + {"from": ".js$", "to": ""} + ] + } + } + ] +} diff --git a/js_client/lib/index.js b/js_client/lib/index.js new file mode 100644 index 0000000..254c253 --- /dev/null +++ b/js_client/lib/index.js @@ -0,0 +1,181 @@ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.WebSocketBridge = undefined; + +var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; + +var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); + +var _reconnectingWebsocket = require('reconnecting-websocket'); + +var _reconnectingWebsocket2 = _interopRequireDefault(_reconnectingWebsocket); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +var noop = function noop() {}; + +/** + * Bridge between Channels and plain javascript. + * + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + * webSocketBridge.listen(function(action, stream) { + * console.log(action, stream); + * }); + */ + +var WebSocketBridge = function () { + function WebSocketBridge(options) { + _classCallCheck(this, WebSocketBridge); + + this._socket = null; + this.streams = {}; + this.default_cb = null; + this.options = _extends({}, { + onopen: noop + }, options); + } + + /** + * Connect to the websocket server + * + * @param {String} [url] The url of the websocket. Defaults to + * `window.location.host` + * @param {String[]|String} [protocols] Optional string or array of protocols. + * @param {Object} options Object of options for [`reconnecting-websocket`](https://github.com/joewalnes/reconnecting-websocket#options-1). + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + */ + + + _createClass(WebSocketBridge, [{ + key: 'connect', + value: function connect(url, protocols, options) { + var _url = void 0; + if (url === undefined) { + // Use wss:// if running on https:// + var scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; + _url = scheme + '://' + window.location.host + '/ws'; + } else { + _url = url; + } + this._socket = new _reconnectingWebsocket2.default(_url, protocols, options); + } + + /** + * Starts listening for messages on the websocket, demultiplexing if necessary. + * + * @param {Function} [cb] Callback to be execute when a message + * arrives. The callback will receive `action` and `stream` parameters + * + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + * webSocketBridge.listen(function(action, stream) { + * console.log(action, stream); + * }); + */ + + }, { + key: 'listen', + value: function listen(cb) { + var _this = this; + + this.default_cb = cb; + this._socket.onmessage = function (event) { + var msg = JSON.parse(event.data); + var action = void 0; + var stream = void 0; + + if (msg.stream !== undefined) { + action = msg.payload; + stream = msg.stream; + var stream_cb = _this.streams[stream]; + stream_cb ? stream_cb(action, stream) : null; + } else { + action = msg; + stream = null; + _this.default_cb ? _this.default_cb(action, stream) : null; + } + }; + + this._socket.onopen = this.options.onopen; + } + + /** + * Adds a 'stream handler' callback. Messages coming from the specified stream + * will call the specified callback. + * + * @param {String} stream The stream name + * @param {Function} cb Callback to be execute when a message + * arrives. The callback will receive `action` and `stream` parameters. + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + * webSocketBridge.listen(); + * webSocketBridge.demultiplex('mystream', function(action, stream) { + * console.log(action, stream); + * }); + * webSocketBridge.demultiplex('myotherstream', function(action, stream) { + * console.info(action, stream); + * }); + */ + + }, { + key: 'demultiplex', + value: function demultiplex(stream, cb) { + this.streams[stream] = cb; + } + + /** + * Sends a message to the reply channel. + * + * @param {Object} msg The message + * + * @example + * webSocketBridge.send({prop1: 'value1', prop2: 'value1'}); + */ + + }, { + key: 'send', + value: function send(msg) { + this._socket.send(JSON.stringify(msg)); + } + + /** + * Returns an object to send messages to a specific stream + * + * @param {String} stream The stream name + * @return {Object} convenience object to send messages to `stream`. + * @example + * webSocketBridge.stream('mystream').send({prop1: 'value1', prop2: 'value1'}) + */ + + }, { + key: 'stream', + value: function stream(_stream) { + var _this2 = this; + + return { + send: function send(action) { + var msg = { + stream: _stream, + payload: action + }; + _this2._socket.send(JSON.stringify(msg)); + } + }; + } + }]); + + return WebSocketBridge; +}(); + +exports.WebSocketBridge = WebSocketBridge; \ No newline at end of file diff --git a/js_client/package.json b/js_client/package.json new file mode 100644 index 0000000..b62c20e --- /dev/null +++ b/js_client/package.json @@ -0,0 +1,72 @@ +{ + "name": "django-channels", + "version": "0.0.2", + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/django/channels.git" + }, + "main": "lib/index.js", + "scripts": { + "transpile": "rm -rf lib && babel src --out-dir lib", + "docs": "rm -rf docs && esdoc -c esdoc.json", + "test": "jest", + "browserify": "browserify src/index.js -p browserify-banner -s channels -o ../channels/static/channels/js/websocketbridge.js", + "prepublish": "npm run transpile", + "compile": "npm run transpile && npm run browserify" + }, + "files": [ + "lib/index.js" + ], + "license": "BSD-3-Clause", + "dependencies": { + "reconnecting-websocket": "^3.0.3" + }, + "jest": { + "roots": [ + "tests" + ] + }, + "browserify": { + "transform": [ + [ + "babelify" + ] + ] + }, + "devDependencies": { + "babel": "^6.5.2", + "babel-cli": "^6.16.0", + "babel-core": "^6.16.0", + "babel-plugin-transform-inline-environment-variables": "^6.8.0", + "babel-plugin-transform-object-assign": "^6.8.0", + "babel-plugin-transform-runtime": "^6.15.0", + "babel-polyfill": "^6.16.0", + "babel-preset-es2015": "^6.16.0", + "babel-preset-react": "^6.16.0", + "babel-preset-stage-0": "^6.16.0", + "babel-register": "^6.9.0", + "babel-runtime": "^6.11.6", + "babelify": "^7.3.0", + "browserify": "^14.1.0", + "browserify-banner": "^1.0.3", + "esdoc": "^0.5.2", + "esdoc-es7-plugin": "0.0.3", + "esdoc-importpath-plugin": "^0.1.1", + "eslint": "^2.13.1", + "eslint-config-airbnb": "^9.0.1", + "eslint-plugin-import": "^1.9.2", + "eslint-plugin-jsx-a11y": "^1.5.3", + "eslint-plugin-react": "^5.2.2", + "jest": "^19.0.1", + "mock-socket": "^6.0.4", + "react": "^15.4.0", + "react-cookie": "^0.4.8", + "react-dom": "^15.4.0", + "react-redux": "^4.4.6", + "redux": "^3.6.0", + "redux-actions": "^1.0.0", + "redux-logger": "^2.7.4", + "redux-thunk": "^2.1.0" + } +} diff --git a/js_client/src/index.js b/js_client/src/index.js new file mode 100644 index 0000000..6a5ba3e --- /dev/null +++ b/js_client/src/index.js @@ -0,0 +1,139 @@ +import ReconnectingWebSocket from 'reconnecting-websocket'; + + +const noop = (...args) => {}; + +/** + * Bridge between Channels and plain javascript. + * + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + * webSocketBridge.listen(function(action, stream) { + * console.log(action, stream); + * }); + */ +export class WebSocketBridge { + constructor(options) { + this._socket = null; + this.streams = {}; + this.default_cb = null; + this.options = Object.assign({}, { + onopen: noop, + }, options); + } + + /** + * Connect to the websocket server + * + * @param {String} [url] The url of the websocket. Defaults to + * `window.location.host` + * @param {String[]|String} [protocols] Optional string or array of protocols. + * @param {Object} options Object of options for [`reconnecting-websocket`](https://github.com/joewalnes/reconnecting-websocket#options-1). + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + */ + connect(url, protocols, options) { + let _url; + if (url === undefined) { + // Use wss:// if running on https:// + const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; + _url = `${scheme}://${window.location.host}/ws`; + } else { + _url = url; + } + this._socket = new ReconnectingWebSocket(_url, protocols, options); + } + + /** + * Starts listening for messages on the websocket, demultiplexing if necessary. + * + * @param {Function} [cb] Callback to be execute when a message + * arrives. The callback will receive `action` and `stream` parameters + * + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + * webSocketBridge.listen(function(action, stream) { + * console.log(action, stream); + * }); + */ + listen(cb) { + this.default_cb = cb; + this._socket.onmessage = (event) => { + const msg = JSON.parse(event.data); + let action; + let stream; + + if (msg.stream !== undefined) { + action = msg.payload; + stream = msg.stream; + const stream_cb = this.streams[stream]; + stream_cb ? stream_cb(action, stream) : null; + } else { + action = msg; + stream = null; + this.default_cb ? this.default_cb(action, stream) : null; + } + }; + + this._socket.onopen = this.options.onopen; + } + + /** + * Adds a 'stream handler' callback. Messages coming from the specified stream + * will call the specified callback. + * + * @param {String} stream The stream name + * @param {Function} cb Callback to be execute when a message + * arrives. The callback will receive `action` and `stream` parameters. + + * @example + * const webSocketBridge = new WebSocketBridge(); + * webSocketBridge.connect(); + * webSocketBridge.listen(); + * webSocketBridge.demultiplex('mystream', function(action, stream) { + * console.log(action, stream); + * }); + * webSocketBridge.demultiplex('myotherstream', function(action, stream) { + * console.info(action, stream); + * }); + */ + demultiplex(stream, cb) { + this.streams[stream] = cb; + } + + /** + * Sends a message to the reply channel. + * + * @param {Object} msg The message + * + * @example + * webSocketBridge.send({prop1: 'value1', prop2: 'value1'}); + */ + send(msg) { + this._socket.send(JSON.stringify(msg)); + } + + /** + * Returns an object to send messages to a specific stream + * + * @param {String} stream The stream name + * @return {Object} convenience object to send messages to `stream`. + * @example + * webSocketBridge.stream('mystream').send({prop1: 'value1', prop2: 'value1'}) + */ + stream(stream) { + return { + send: (action) => { + const msg = { + stream, + payload: action + } + this._socket.send(JSON.stringify(msg)); + } + } + } + +} diff --git a/js_client/tests/websocketbridge.test.js b/js_client/tests/websocketbridge.test.js new file mode 100644 index 0000000..4ed74ea --- /dev/null +++ b/js_client/tests/websocketbridge.test.js @@ -0,0 +1,137 @@ +import { WebSocket, Server } from 'mock-socket'; +import { WebSocketBridge } from '../src/'; + + + +describe('WebSocketBridge', () => { + const mockServer = new Server('ws://localhost'); + const serverReceivedMessage = jest.fn(); + mockServer.on('message', serverReceivedMessage); + + beforeEach(() => { + serverReceivedMessage.mockReset(); + }); + + it('Connects', () => { + const webSocketBridge = new WebSocketBridge(); + webSocketBridge.connect('ws://localhost'); + }); + it('Processes messages', () => { + const webSocketBridge = new WebSocketBridge(); + const myMock = jest.fn(); + + webSocketBridge.connect('ws://localhost'); + webSocketBridge.listen(myMock); + + mockServer.send('{"type": "test", "payload": "message 1"}'); + mockServer.send('{"type": "test", "payload": "message 2"}'); + + expect(myMock.mock.calls.length).toBe(2); + expect(myMock.mock.calls[0][0]).toEqual({"type": "test", "payload": "message 1"}); + expect(myMock.mock.calls[0][1]).toBe(null); + }); + it('Ignores multiplexed messages for unregistered streams', () => { + const webSocketBridge = new WebSocketBridge(); + const myMock = jest.fn(); + + webSocketBridge.connect('ws://localhost'); + webSocketBridge.listen(myMock); + + mockServer.send('{"stream": "stream1", "payload": {"type": "test", "payload": "message 1"}}'); + expect(myMock.mock.calls.length).toBe(0); + + }); + it('Demultiplexes messages only when they have a stream', () => { + const webSocketBridge = new WebSocketBridge(); + const myMock = jest.fn(); + const myMock2 = jest.fn(); + const myMock3 = jest.fn(); + + webSocketBridge.connect('ws://localhost'); + webSocketBridge.listen(myMock); + webSocketBridge.demultiplex('stream1', myMock2); + webSocketBridge.demultiplex('stream2', myMock3); + + mockServer.send('{"type": "test", "payload": "message 1"}'); + expect(myMock.mock.calls.length).toBe(1); + expect(myMock2.mock.calls.length).toBe(0); + expect(myMock3.mock.calls.length).toBe(0); + + mockServer.send('{"stream": "stream1", "payload": {"type": "test", "payload": "message 1"}}'); + + expect(myMock.mock.calls.length).toBe(1); + expect(myMock2.mock.calls.length).toBe(1); + expect(myMock3.mock.calls.length).toBe(0); + + expect(myMock2.mock.calls[0][0]).toEqual({"type": "test", "payload": "message 1"}); + expect(myMock2.mock.calls[0][1]).toBe("stream1"); + + mockServer.send('{"stream": "stream2", "payload": {"type": "test", "payload": "message 2"}}'); + + expect(myMock.mock.calls.length).toBe(1); + expect(myMock2.mock.calls.length).toBe(1); + expect(myMock3.mock.calls.length).toBe(1); + + expect(myMock3.mock.calls[0][0]).toEqual({"type": "test", "payload": "message 2"}); + expect(myMock3.mock.calls[0][1]).toBe("stream2"); + }); + it('Demultiplexes messages', () => { + const webSocketBridge = new WebSocketBridge(); + const myMock = jest.fn(); + const myMock2 = jest.fn(); + + webSocketBridge.connect('ws://localhost'); + webSocketBridge.listen(); + + webSocketBridge.demultiplex('stream1', myMock); + webSocketBridge.demultiplex('stream2', myMock2); + + mockServer.send('{"type": "test", "payload": "message 1"}'); + mockServer.send('{"type": "test", "payload": "message 2"}'); + + expect(myMock.mock.calls.length).toBe(0); + expect(myMock2.mock.calls.length).toBe(0); + + mockServer.send('{"stream": "stream1", "payload": {"type": "test", "payload": "message 1"}}'); + + expect(myMock.mock.calls.length).toBe(1); + + expect(myMock2.mock.calls.length).toBe(0); + + expect(myMock.mock.calls[0][0]).toEqual({"type": "test", "payload": "message 1"}); + expect(myMock.mock.calls[0][1]).toBe("stream1"); + + mockServer.send('{"stream": "stream2", "payload": {"type": "test", "payload": "message 2"}}'); + + expect(myMock.mock.calls.length).toBe(1); + expect(myMock2.mock.calls.length).toBe(1); + + + expect(myMock2.mock.calls[0][0]).toEqual({"type": "test", "payload": "message 2"}); + expect(myMock2.mock.calls[0][1]).toBe("stream2"); + + }); + it('Sends messages', () => { + const webSocketBridge = new WebSocketBridge(); + + webSocketBridge.connect('ws://localhost'); + webSocketBridge.send({"type": "test", "payload": "message 1"}); + + expect(serverReceivedMessage.mock.calls.length).toBe(1); + expect(serverReceivedMessage.mock.calls[0][0]).toEqual(JSON.stringify({"type": "test", "payload": "message 1"})); + }); + it('Multiplexes messages', () => { + const webSocketBridge = new WebSocketBridge(); + + webSocketBridge.connect('ws://localhost'); + webSocketBridge.stream('stream1').send({"type": "test", "payload": "message 1"}); + + expect(serverReceivedMessage.mock.calls.length).toBe(1); + expect(serverReceivedMessage.mock.calls[0][0]).toEqual(JSON.stringify({ + "stream": "stream1", + "payload": { + "type": "test", "payload": "message 1", + }, + })); + }); +}); diff --git a/setup.cfg b/setup.cfg index 6923a28..7efaff1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [flake8] -exclude = venv/*,tox/*,docs/*,testproject/* +exclude = venv/*,tox/*,docs/*,testproject/*,js_client/* ignore = E123,E128,E402,W503,E731,W601 max-line-length = 119