├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── index.js ├── lib ├── handlers │ ├── handler.js │ └── sails.js ├── jar.js ├── manager.js ├── util.js └── wsabi.js ├── package.json ├── readme.md └── test ├── handlers.test.js └── manger.test.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb/base", 3 | "rules": { 4 | "indent": [2, 4], 5 | "func-names": 0, 6 | "no-param-reassign": 0 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #### joe made this: http://goel.io/joe 2 | 3 | #####=== Node ===##### 4 | 5 | # Logs 6 | logs 7 | *.log 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directory 30 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 31 | node_modules 32 | 33 | # Debug log from npm 34 | npm-debug.log 35 | 36 | /doc 37 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | - "5" 5 | git: 6 | depth: 10 7 | script: 8 | - "npm run travis" 9 | after_script: 10 | - "npm install coveralls@2.11.x && cat coverage/lcov.info | coveralls" 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Beam Interactive, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/wsabi.js'); 2 | -------------------------------------------------------------------------------- /lib/handlers/handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventEmitter = require('events').EventEmitter; 4 | const util = require('util'); 5 | 6 | /** 7 | * The response object sent to sockets. It's subset of the "res" sent from 8 | * the .inject method. Note that binary data will not be accepted and lead 9 | * to undefined behaviour. 10 | * 11 | * @access public 12 | * @typedef {Object} Response 13 | * @property {Number} statusCode 14 | * @property {Object} headers 15 | * @property {String} payload 16 | * @see http://hapijs.com/api#serverinjectoptions-callback 17 | */ 18 | 19 | /** 20 | * The callback, send in the "request" event, should be called when the 21 | * response is prepared to be sent down to the client. It takes, as its 22 | * first and only argument, a Response object. 23 | * 24 | * @access public 25 | * @callback RequestCallback 26 | * @typedef {Object} Response 27 | */ 28 | 29 | /** 30 | * This is triggered when a response is sent. That is, when a callback 31 | * is called from a Handler. 32 | * 33 | * @access public 34 | * @event Handler#response 35 | * @param {Response} 36 | */ 37 | 38 | /** 39 | * The request event is sent whenever the socket sends up a *valid* HTTP 40 | * request that should be emulated. Notably, in the request, the "headers" 41 | * will be merged in with the headers used originally to send the request, 42 | * so you don't need to worry about resending session information. 43 | * 44 | * Also, if there are cookie updates sent down the socket, we'll take care 45 | * of them, but cookie updates you may get if you use standard HTTP in 46 | * parallel with this will not be recorded. 47 | * 48 | * The event data will be well-formed "options" suitable for use in the 49 | * Hapi "inject" method, as well as a callback function. 50 | * 51 | * @access public 52 | * @event Handler#request 53 | * @type {Object} 54 | * @property {String} method The request method (GET, POST, etc) 55 | * of the request. 56 | * @property {String} url The path or fully qualified URL the request is 57 | * sent to. 58 | * @property {Object.=} headers Key/value header information 59 | * @property {Object.=} payload The request body. 60 | * @property {RequestCallback} The request callback to trigger after we're 61 | * all done. 62 | * @see http://hapijs.com/api#serverinjectoptions-callback 63 | */ 64 | 65 | 66 | /** 67 | * Base class that handlers extend from. Essentially they're responsible 68 | * for parsing incoming requests from sockets, and dispensing replies 69 | * back out. 70 | * 71 | * @interface 72 | * @access protected 73 | * @param {SocketIO.Socket} socket 74 | */ 75 | function Handler(socket) { 76 | EventEmitter.call(this); 77 | this.socket = socket; 78 | this.open = false; 79 | } 80 | util.inherits(Handler, EventEmitter); 81 | 82 | /** 83 | * Called when the socket is open and ready to get messages. 84 | * @access public 85 | */ 86 | Handler.prototype.boot = function () { 87 | this.open = true; 88 | }; 89 | 90 | /** 91 | * Called when the socket is closed. No messages should be sent 92 | * or received after this time. 93 | * @access public 94 | */ 95 | Handler.prototype.close = function () { 96 | this.open = false; 97 | this.removeAllListeners(); 98 | }; 99 | 100 | /** 101 | * List of valid HTTP methods. 102 | * @type {String[]} 103 | */ 104 | const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD']; 105 | 106 | /** 107 | * Validates and dispatches an outgoing request, returning an string error if 108 | * invalid, emitting an event otherwise. 109 | * 110 | * @access protected 111 | * @fires Handler#request 112 | * @param {Object} req 113 | * @return {String|Undefined} 114 | */ 115 | Handler.prototype.dispatch = function (req, callback) { 116 | // Make sure basic stuff is correct. 117 | if (methods.indexOf(req.method) === -1) return 'Invalid method.'; 118 | if (typeof req.url !== 'string') return 'Invalid URL.'; 119 | if (typeof req.headers !== 'object') return 'Invalid headers.'; 120 | 121 | // Disallow internal settings that could screw things up. 122 | if (req.credentials || req.simulate) return 'Invalid request.'; 123 | 124 | // Make sure keys are strings, not anything bizarre. 125 | for (const key in req.headers) { 126 | if (typeof key !== 'string' || typeof req.headers[key] !== 'string') { 127 | return 'Invalid headers.'; 128 | } 129 | } 130 | 131 | // At this point, we're good. Emit it! 132 | this.emit('request', req, callback); 133 | }; 134 | 135 | module.exports = Handler; 136 | -------------------------------------------------------------------------------- /lib/handlers/sails.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Boom = require('boom'); 4 | const Handler = require('./handler'); 5 | 6 | const bind = require('../util').bind; 7 | const util = require('util'); 8 | 9 | /** 10 | * Valid event/method names that Sails.io.js can send. 11 | * Based on the code in http://git.io/vvVs4. 12 | * @access private 13 | * @type {String[]} 14 | */ 15 | const events = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head']; 16 | 17 | function noop() {} 18 | 19 | /** 20 | * Handler for the Sails socket protocol. This was originally designed 21 | * as we were porting from Sails to Hapi, so this is backwards compatible 22 | * with the Sails.io.js 0.11 protocl. 23 | * 24 | * @constructor 25 | * @access public 26 | * @augments Handler 27 | */ 28 | function SailsHandler() { 29 | Handler.apply(this, arguments); 30 | } 31 | util.inherits(SailsHandler, Handler); 32 | 33 | SailsHandler.prototype.boot = function () { 34 | Handler.prototype.boot.call(this); 35 | 36 | const fn = bind(this.onRequest, this); 37 | for (let i = 0; i < events.length; i++) { 38 | this.socket.on(events[i], fn); 39 | } 40 | }; 41 | 42 | /** 43 | * This method is called when we get a socket request. We validate the 44 | * incoming event, and fire a request event if it's valid. 45 | * @access private 46 | * @param {Object} ev 47 | * @param {Function} callback 48 | */ 49 | SailsHandler.prototype.onRequest = function (ev, callback) { 50 | ev = ev || {}; 51 | 52 | const request = { 53 | method: String(ev.method).toUpperCase(), 54 | url: ev.url, 55 | headers: ev.headers || {}, 56 | payload: ev.data || undefined, 57 | }; 58 | 59 | const reqCallback = this.respond(callback || noop); 60 | 61 | const err = this.dispatch(request, reqCallback); 62 | if (err) reqCallback(Boom.badRequest(err).output); 63 | }; 64 | 65 | /** 66 | * Generator for a "response" function. When invoked with a standard 67 | * response, it parses the response and sends down a Sails-compatible 68 | * reply in the callback. 69 | * 70 | * @param {Function} callback 71 | * @return {Function} 72 | */ 73 | SailsHandler.prototype.respond = function (callback) { 74 | return function (response) { 75 | // Use the rawPayload and turn it to a utf8 string - Hapi 76 | // seems to have issues with special characters. 77 | let body = response.rawPayload ? response.rawPayload.toString('utf8') : response.payload; 78 | try { 79 | body = JSON.parse(body); 80 | } catch (e) { 81 | // ignore parsing errors 82 | } 83 | 84 | callback({ 85 | body, 86 | headers: response.headers, 87 | statusCode: response.statusCode, 88 | }); 89 | }; 90 | }; 91 | 92 | module.exports = SailsHandler; 93 | -------------------------------------------------------------------------------- /lib/jar.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Cookie = require('cookiejar'); 4 | 5 | /** 6 | * Extremely minimalist wrapper of a cookie jar to 7 | * store multiple domain/path agnostic cookies. 8 | */ 9 | function Jar() { 10 | this.cookies = {}; 11 | } 12 | 13 | /** 14 | * Removes all existing cookies from the jar. 15 | */ 16 | Jar.prototype.clear = function () { 17 | this.cookies = {}; 18 | }; 19 | 20 | /** 21 | * Adds a cookie string, with optional semicolon-delimited pairs. 22 | * @param {String} cookies 23 | */ 24 | Jar.prototype.setCookies = function (cookies) { 25 | cookies = Array.isArray(cookies) ? cookies : cookies.split('; '); 26 | 27 | for (let i = 0; i < cookies.length; i++) { 28 | const cookie = new Cookie.Cookie(cookies[i].trim()); 29 | this.cookies[cookie.name] = cookie; 30 | } 31 | }; 32 | 33 | /** 34 | * Returns a cookie string for cookies in the jar. 35 | * @return {String} 36 | */ 37 | Jar.prototype.getCookies = function () { 38 | return Object.keys(this.cookies) 39 | .map((key) => this.cookies[key].toValueString()) 40 | .join('; '); 41 | }; 42 | 43 | module.exports = Jar; 44 | -------------------------------------------------------------------------------- /lib/manager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('crypto'); 4 | const Hoek = require('hoek'); 5 | 6 | const Jar = require('./jar'); 7 | const lowerKeys = require('./util').lowerKeys; 8 | 9 | /** 10 | * The manager is responsible for handling the websocket connection and 11 | * ferrying data between the connection and Hapi. 12 | * 13 | * @access public 14 | * @param {Hapi.Server} server 15 | * @param {SocketIO.Socket} socket 16 | * @param {Object} config 17 | */ 18 | function Manager(server, socket, config) { 19 | this.server = server; 20 | this.socket = socket; 21 | this.config = config; 22 | this.handler = this.detectVersion(); 23 | 24 | this.id = crypto.randomBytes(32).toString('hex'); 25 | socket.handshake.headers = lowerKeys(socket.handshake.headers); 26 | 27 | if (this.config.cookies) { 28 | // Handle cookies for the session in this nice jar. 29 | this.jar = new Jar(); 30 | // Start handling. 31 | this.updateCookies(socket.handshake.headers, 'cookie'); 32 | } 33 | } 34 | 35 | /** 36 | * Boots up the manager and starts listening on the connection. 37 | * @access public 38 | * @param {Object} registry Object the manager registers itself in. 39 | */ 40 | Manager.prototype.boot = function (registry) { 41 | registry[this.id] = this; 42 | 43 | // When the handler tells us we have a request, inject it into the 44 | // server and wait for the response. 45 | this.handler.on('request', (req, callback) => { 46 | req.headers['x-wsabi-manager'] = this.id; 47 | req.headers = lowerKeys(req.headers); 48 | this.syncCookies(req.headers); 49 | this.addStickyHeaders(req); 50 | 51 | this.server.inject(req, (res) => { 52 | // Check to make sure the client didn't disconnect in the 53 | // middle of making a request. That would be quite rude. 54 | if (this.handler) { 55 | this.updateCookies(lowerKeys(res.headers), 'set-cookie'); 56 | this.stripHeaders(res); 57 | callback(res); 58 | } 59 | }); 60 | }); 61 | 62 | // When the socket disconnects, close the handler and null for gc. 63 | this.socket.on('disconnect', () => { 64 | this.handler.close(); 65 | this.handler = null; 66 | this.jar = null; 67 | delete registry[this.id]; 68 | }); 69 | 70 | this.handler.boot(); 71 | }; 72 | 73 | /** 74 | * Syncs the cookies with what's in the headers, if cookies are enabled. 75 | * It adds cookies to the chat, and copies whatever is in the jar 76 | * to the headers. 77 | * 78 | * @access private 79 | * @param {Object} headers 80 | */ 81 | Manager.prototype.syncCookies = function (headers) { 82 | if (!this.config.cookies || !headers) return; 83 | 84 | if (headers.cookie) { 85 | this.jar.clear(); 86 | this.updateCookies(headers, 'cookie'); 87 | } 88 | 89 | headers.cookie = this.jar.getCookies(); 90 | }; 91 | 92 | /** 93 | * Updates the cookies stored on the manager. 94 | * 95 | * @access private 96 | * @param {Object} headers 97 | * @param {String} prop 98 | */ 99 | Manager.prototype.updateCookies = function (headers, prop) { 100 | if (!this.config.cookies || !headers) return; 101 | 102 | const header = headers[prop]; 103 | if (!header) return; 104 | 105 | try { 106 | this.jar.setCookies(header); 107 | } catch (c) { 108 | // ignore errors in parsing cookies. 109 | } 110 | }; 111 | 112 | /** 113 | * Adds "sticky" headers from the handshake onto the request. 114 | * @param {Object} req 115 | */ 116 | Manager.prototype.addStickyHeaders = function (req) { 117 | const sticky = this.config.sticky; 118 | for (let i = 0; i < sticky.length; i++) { 119 | req.headers[sticky[i]] = this.socket.handshake.headers[sticky[i]]; 120 | } 121 | }; 122 | 123 | /** 124 | * Strips headers from the response. 125 | * @param {Object} res 126 | */ 127 | Manager.prototype.stripHeaders = function (res) { 128 | const strip = this.config.strip; 129 | for (let i = 0; i < strip.length; i++) { 130 | delete res.headers[strip[i]]; 131 | } 132 | }; 133 | 134 | /** 135 | * List of protocol handlers available. 136 | * @access private 137 | * @type {Object.} 138 | */ 139 | const Handlers = Object.freeze({ 140 | Sails: require('./handlers/sails'), 141 | }); 142 | 143 | /** 144 | * Detects the version that the socket connection should be served with. 145 | * It tries to read valid GET parameters. It returns a constructor 146 | * that should be invoked with the socket. 147 | * 148 | * @access private 149 | * @param {Socket.IO} socket 150 | * @return {Handler} 151 | */ 152 | Manager.prototype.detectVersion = function () { 153 | if (Hoek.reach(this.socket, 'handshake.query.__sails_io_sdk_version')) { 154 | return new Handlers.Sails(this.socket); 155 | } 156 | 157 | // Default to the sails transport. 158 | this.socket.emit('warn', { error: 'Unknown protocol; defaulting to Sails.' }); 159 | return new Handlers.Sails(this.socket); 160 | }; 161 | 162 | module.exports = Manager; 163 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Util function to bind a function in the context. 5 | * Native .bind is terribly slow. 6 | * 7 | * @access public 8 | * @param {Function} fn 9 | * @param {*} context 10 | * @return {Function} 11 | */ 12 | module.exports.bind = function (fn, context) { 13 | return function () { 14 | fn.apply(context, arguments); 15 | }; 16 | }; 17 | 18 | /** 19 | * Makes all keys of the object lower-case, returning a new object. 20 | * This is used for header normalization; according to RFC 2616, HTTP 21 | * header names are case insensitive, so this is fine. 22 | * 23 | * @param {Object} obj 24 | * @return {Object} 25 | */ 26 | module.exports.lowerKeys = function (obj) { 27 | const out = {}; 28 | Object.keys(obj).forEach((key) => { 29 | out[key.toLowerCase()] = obj[key]; 30 | }); 31 | 32 | return out; 33 | }; 34 | -------------------------------------------------------------------------------- /lib/wsabi.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const SocketIO = require('socket.io'); 4 | const Manager = require('./manager'); 5 | const Hoek = require('hoek'); 6 | const Boom = require('boom'); 7 | 8 | const wsabi = module.exports = {}; 9 | 10 | /** 11 | * Registers a new wsabi (Socket.io) instance on the server, and starts 12 | * it listening. It takes "io" options that are passed into the socket.io 13 | * server. 14 | * 15 | * It also registers a preAuth point that allows you to disallow sockets 16 | * from accessing certain routes. By default sockets are allowed; to 17 | * disallow, pass an option like `plugins: { wsabi: { enabled: false }}`. 18 | * 19 | * @access public 20 | * @param {Hapi.Server} server 21 | * @param {Object=} options 22 | * @property {Object.} io 23 | * Options to pass to the socket.io server. 24 | * @property {String[]} sticky 25 | * Headers to store and pass on each request from the handshake. 26 | * @property {String[]} sticky 27 | * Headers to strip from websocket responses. 28 | * @property {Boolean} [cookies=true] Whether cookies should be managed. 29 | * @param {Function} next 30 | */ 31 | wsabi.register = function (server, options, next) { 32 | const config = Hoek.applyToDefaults({ 33 | io: {}, 34 | cookies: true, 35 | sticky: [], 36 | strip: [], 37 | errors: { 38 | disabled: Boom.badRequest('Websockets are not allowed on this route.'), 39 | required: Boom.badRequest('This route may only be accessed via websockets.'), 40 | }, 41 | }, options); 42 | 43 | // Start the socket server. 44 | const io = SocketIO.listen(server.listener, config.io); 45 | const managers = {}; 46 | io.sockets.on('connection', (socket) => { 47 | new Manager(server, socket, config).boot(managers); 48 | }); 49 | 50 | server.expose('io', io); 51 | 52 | // Register the preauth plugin to enable filtering of requests. 53 | server.ext('onPreAuth', (req, reply) => { 54 | const settings = req.route.settings.plugins; 55 | // Error if we're using sockets and wsabi is disabled. 56 | if (req.websocket && Hoek.reach(settings, 'wsabi.enabled') === false) { 57 | return reply(Hoek.reach(settings, 'wsabi.errors.disabled') || config.errors.disabled); 58 | } 59 | 60 | // Error if we're not using sockets and wsabi is required. 61 | if (!req.websocket && Hoek.reach(settings, 'wsabi.required')) { 62 | return reply(Hoek.reach(settings, 'wsabi.errors.disabled') || config.errors.required); 63 | } 64 | 65 | return reply.continue(); 66 | }); 67 | 68 | // When the server gets a request, add the websocket if necessary. 69 | // We have to take this circuitous route :/ 70 | server.ext('onRequest', (req, reply) => { 71 | const id = req.headers['x-wsabi-manager']; 72 | const manager = id && managers[id]; 73 | if (manager) { 74 | req.websocket = manager.socket; 75 | } 76 | 77 | return reply.continue(); 78 | }); 79 | 80 | next(); 81 | }; 82 | 83 | wsabi.register.attributes = { pkg: require('../package') }; 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wsabi", 3 | "version": "3.1.0", 4 | "description": "HTTP-over-websocket layer for the Hapi web framework.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "node node_modules/mocha/bin/mocha test --recursive && npm run lint", 8 | "lint": "node node_modules/eslint/bin/eslint.js lib", 9 | "doc": "jsdoc lib -r -p -P package.json -d doc", 10 | "cover": "node node_modules/istanbul/lib/cli cover node_modules/mocha/bin/_mocha -- test --recursive", 11 | "travis": "npm run lint && node node_modules/istanbul/lib/cli cover node_modules/mocha/bin/_mocha --report lcovonly -- test --recursive" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/MCProHosting/wsabi" 16 | }, 17 | "keywords": [ 18 | "hapi", 19 | "websocket", 20 | "http", 21 | "layer" 22 | ], 23 | "author": "Connor Peet ", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/MCProHosting/wsabi/issues" 27 | }, 28 | "homepage": "https://github.com/MCProHosting/wsabi", 29 | "dependencies": { 30 | "boom": "^3.1.2", 31 | "cookiejar": "^2.0.1", 32 | "hoek": "^3.0.4", 33 | "socket.io": "^1.3.5" 34 | }, 35 | "devDependencies": { 36 | "chai": "^3.5.0", 37 | "eslint": "^1.10.3", 38 | "eslint-config-airbnb": "^5.0.0", 39 | "hapi": "^13.0.0", 40 | "istanbul": "^0.4.2", 41 | "mocha": "^2.2.4", 42 | "sinon": "^1.14.1" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![Wsabi](http://i.imgur.com/pb4uMWM.png) 2 | 3 | [![Build Status](https://img.shields.io/travis/WatchBeam/wsabi.svg?style=flat-square)](https://travis-ci.org/WatchBeam/wsabi) [![Coverage Status](https://img.shields.io/coveralls/WatchBeam/wsabi.svg?style=flat-square)](https://coveralls.io/r/WatchBeam/wsabi) 4 | 5 | Wsabi is a layer which allows you to call Hapi http endpoints from websockets, basically serving as a bridge between Socket.io and Hapi's server.inject. It was originally built to be backwards compatible with the [Sails.js](http://sailsjs.org/#!/) websocket system, during a backend port. 6 | 7 | ## Usage 8 | 9 | To Wsabi, simply register it on your server. 10 | 11 | ```js 12 | server.register({ register: require('wsabi') }, function (err) { 13 | // ... 14 | }); 15 | ``` 16 | 17 | Options: 18 | 19 | * `io` defaults to an empty object. List of [options](https://github.com/Automattic/engine.io#methods-1) to pass to the socket.io server, 20 | * `cookies` defaults to "true". Determines whether Wsabi should "manage" the session cookies for you - see the note below. 21 | * `sticky` defaults to `[]`. Should be an array of headers you want to keep from the handshake and pass directly into the server. Setting sticky to `["x-forwarded-for"]` may be useful. The client will not be able to overwrite these. 22 | * `strip` defaults to `[]`. Should be an array of headers you don't care to send back down to the client over websockets. 23 | * `errors` is an object 24 | * `required` is the reply sent if sockets are required on the route, but it's accessed over HTTP. Defaults to a `Boom.badRequest` instance. 25 | * `disabled` is the reply sent if sockets are disabled on the route and it's attempted to be accessed over sockets. Defaults to a `Boom.badRequest` instance. 26 | 27 | After this, you can then connect to your server using any supported client library, including: 28 | 29 | * [Sails.io.js](https://github.com/balderdashy/sails.io.js). 30 | * More to come? 31 | 32 | Routes also have their own options that you can pass in the plugin config: 33 | * `required` defaults to `false`: setting it to true will cause an error to be sent if the socket is accessed over plain HTTP. 34 | * `enabled` defaults to `true`: setting it to false will cause an error to be sent if the socket is accessed over websockets. 35 | * `errors` object can be passed in for custom error overrides. 36 | 37 | ```js 38 | // Example of a route with wsabi disabled: 39 | server.route({ 40 | method: 'GET', 41 | path: '/', 42 | config: { 43 | plugins: { wsabi: { enabled: false }} 44 | } 45 | // ... 46 | }) 47 | ``` 48 | 49 | If you'd like to access the websocket itself in your request, you can access `req.websocket`. That may also be used for checking if a route is running under websockets. 50 | 51 | You can also access the socket.io server directly after registration via `server.plugins.wsabi.io`. 52 | 53 | ### A Note About Sessions 54 | 55 | The session "state" is stored on the socket connection. Cookies passed in the initial Socket.io handshake request will be passed on automatically in every request on that socket. If a response includes a `set-cookie` header, we'll update the stored cookie jar, and if you send in a request with a `Cookie` header then the cookie jar will be updated appropriately. 56 | 57 | You can disable handling of cookies by passing `cookies: false` in the plugin config. When disabled, only the cookies explicitly sent on each request will be used. 58 | 59 | ## License 60 | 61 | This software is MIT licensed, copyright 2016 by Beam Interactive, Inc. 62 | 63 | #### Why the name? 64 | 65 | Because it's Hapi websockets, and if you say "WS Hapi" quickly several times it sounds like "wasabi". Wasabi itself was already taken on npm, so we have wsabi! Oh, and because sushi is awesome. 66 | -------------------------------------------------------------------------------- /test/handlers.test.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter; 2 | var expect = require('chai').expect; 3 | 4 | describe('sails handler', function () { 5 | var Handler = require('../lib/handlers/sails'); 6 | var socket, handler; 7 | 8 | beforeEach(function () { 9 | socket = new EventEmitter(); 10 | handler = new Handler(socket); 11 | handler.boot(); 12 | }); 13 | 14 | afterEach(function () { 15 | handler.close(); 16 | }); 17 | 18 | it('parses valid incoming requests', function (done) { 19 | handler.on('request', function (req) { 20 | expect(req.method).to.equal('GET'); 21 | expect(req.url).to.equal('/api/v1/users/current?'); 22 | expect(req.headers).to.deep.equal({ a: 'b' }); 23 | expect(req.payload).to.deep.equal({ b: 'c' }); 24 | done(); 25 | }); 26 | socket.emit('get', { method: 'get', headers: { a: 'b' }, data: { b: 'c' }, url: '/api/v1/users/current?' }); 27 | }); 28 | 29 | it('disallows attempts at passing credentials', function (done) { 30 | handler.on('request', function (req) { 31 | expect(req.credentials).not.to.be.defined; 32 | done(); 33 | }); 34 | socket.emit('get', { method: 'get', headers: { a: 'b' }, data: { b: 'c' }, url: '/api/v1/users/current?', credentials: {} }); 35 | }); 36 | 37 | it('sends valid responses', function (done) { 38 | handler.on('request', function (req, callback) { 39 | callback({ statusCode: 200, headers: { foo: 'bar' }, rawPayload: new Buffer('{"a":42}')}); 40 | done(); 41 | }); 42 | socket.emit('get', { method: 'get', headers: {}, data: {}, url: '/api/v1/users/current?' }, function (res) { 43 | expect(res).to.deep.equal({ 44 | body: { a: 42 }, 45 | headers: { foo: 'bar' }, 46 | statusCode: 200 47 | }); 48 | }); 49 | }); 50 | 51 | it('does not crash when no ACK callback', function (done) { 52 | handler.on('request', function (req, callback) { 53 | callback({ statusCode: 200, headers: { foo: 'bar' }, rawPayload: new Buffer('{"a":42}')}); 54 | done(); 55 | }); 56 | socket.emit('get', { method: 'get', headers: {}, data: {}, url: '/api/v1/users/current?' }); 57 | }); 58 | 59 | 60 | [ 61 | 'hola', 62 | {}, 63 | null, 64 | { method: 'blip', url: '/', headers: {}, data: {} }, 65 | { method: 'get', headers: {}, data: {} }, 66 | { method: 'get', headers: { 1: 2 }, data: {}, url: '/api/v1/users/current?' } 67 | ].forEach(function (r, i) { 68 | it('rejects malformed requests #' + i, function (done) { 69 | socket.emit('get', r, function (res) { 70 | expect(res.statusCode).to.equal(400); 71 | done(); 72 | }); 73 | }); 74 | }); 75 | 76 | }); 77 | -------------------------------------------------------------------------------- /test/manger.test.js: -------------------------------------------------------------------------------- 1 | var Hoek = require('hoek'); 2 | var EventEmitter = require('events').EventEmitter; 3 | 4 | var expect = require('chai').expect; 5 | var sinon = require('sinon'); 6 | 7 | var sailsHandshake = { 8 | headers: { 9 | upgrade: 'websocket', 10 | connection: 'upgrade', 11 | host: 'localhost:1337', 12 | 'x-forwarded-for': '10.0.2.2', 13 | pragma: 'no-cache', 14 | 'cache-control': 'no-cache', 15 | origin: 'http://localhost:1337', 16 | 'sec-websocket-version': '13', 17 | 'user-agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36', 18 | 'accept-encoding': 'gzip, deflate, sdch', 19 | 'accept-language': 'en-US,en;q=0.8', 20 | cookie: 'a=b', 21 | 'sec-websocket-key': 'K+mIoFLBjZ6B8JDbrgsOLA==', 22 | 'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits' 23 | }, 24 | time: 'Thu Apr 16 2015 12:25:53 GMT+0000 (UTC)', 25 | address: '127.0.0.1', 26 | xdomain: true, 27 | secure: false, 28 | issued: 1429187153599, 29 | url: '/socket.io/?__sails_io_sdk_version=0.11.0&__sails_io_sdk_platform=node&__sails_io_sdk_language=javascript&EIO=3&transport=websocket', 30 | query: { 31 | __sails_io_sdk_version: '0.11.0', 32 | __sails_io_sdk_platform: 'node', 33 | __sails_io_sdk_language: 'javascript', 34 | EIO: '3', 35 | transport: 'websocket' 36 | } 37 | }; 38 | 39 | 40 | describe('manager', function () { 41 | var Manager = require('../lib/manager'); 42 | var SailsHandler = require('../lib/handlers/sails'); 43 | var manager, config; 44 | 45 | beforeEach(function () { 46 | var socket = new EventEmitter(); 47 | socket.handshake = sailsHandshake; 48 | var server = { inject: sinon.stub() }; 49 | config = { cookies: true, sticky: [], strip: [] }; 50 | manager = new Manager(server, socket, config); 51 | }); 52 | 53 | describe('handler selection', function () { 54 | it('use sails transport as requested', function () { 55 | manager.socket.handshake = sailsHandshake; 56 | expect(manager.detectVersion()).to.be.an.instanceof(SailsHandler); 57 | }); 58 | it('uses sails handler by default', function () { 59 | var h = Hoek.clone(sailsHandshake); 60 | h.query = {}; 61 | manager.socket.handshake = h; 62 | 63 | expect(manager.detectVersion()).to.be.an.instanceof(SailsHandler); 64 | }); 65 | }); 66 | 67 | describe('cookie management', function () { 68 | it('applies stored cookies to header', function () { 69 | var headers = {}; 70 | manager.syncCookies(headers); 71 | expect(headers).to.deep.equal({ cookie: 'a=b' }); 72 | }); 73 | it('reads updated cookies', function () { 74 | var headers = { cookie: 'b=c' }; 75 | manager.syncCookies(headers); 76 | expect(headers).to.deep.equal({ cookie: 'b=c' }); 77 | }); 78 | it('overwrites cookies', function () { 79 | var headers = { cookie: 'a=q' }; 80 | manager.syncCookies(headers); 81 | expect(headers).to.deep.equal({ cookie: 'a=q' }); 82 | }); 83 | it('saves updates', function () { 84 | // once 85 | var headers = { cookie: 'b=c' }; 86 | manager.syncCookies(headers); 87 | expect(headers).to.deep.equal({ cookie: 'b=c' }); 88 | // saved them 89 | headers = {}; 90 | manager.syncCookies(headers); 91 | expect(headers).to.deep.equal({ cookie: 'b=c' }); 92 | }); 93 | 94 | it('also works with set-cookie', function () { 95 | manager.updateCookies({ 'set-cookie': ['b=c'] }, 'set-cookie'); 96 | var headers = {}; 97 | manager.syncCookies(headers); 98 | expect(headers).to.deep.equal({ cookie: 'a=b; b=c' }); 99 | }); 100 | 101 | it('does nothing when cookies disabled', function () { 102 | config.cookies = false; 103 | headers = {}; 104 | manager.syncCookies(headers); 105 | expect(headers).to.deep.equal({}); 106 | }); 107 | 108 | it('does not fail on malformed cookie headers', function () { 109 | expect(function () { 110 | manager.updateCookies({ 'cookie': ['qwert ;yfr fq:$ 02)$gu'] }, 'cookie'); 111 | }).not.to.throw; 112 | }); 113 | }); 114 | 115 | describe('handling', function () { 116 | var handler, registry; 117 | beforeEach(function () { 118 | registry = {}; 119 | handler = manager.handler = new EventEmitter(); 120 | manager.handler.close = sinon.stub(); 121 | manager.handler.boot = sinon.stub(); 122 | manager.boot(registry); 123 | }); 124 | 125 | it('boots and closes on disconnect', function () { 126 | sinon.assert.called(handler.boot); 127 | expect(registry[manager.id]).to.equal(manager); 128 | manager.socket.emit('disconnect'); 129 | sinon.assert.called(handler.close); 130 | expect(registry[manager.id]).to.be.undefined; 131 | }); 132 | 133 | it('run requests', function () { 134 | var req = { headers: { foo: 'bar' }, payload: {}, route: '/' }; 135 | var callback = sinon.stub(); 136 | sinon.stub(manager, 'syncCookies'); 137 | sinon.stub(manager, 'updateCookies'); 138 | 139 | handler.emit('request', req, callback); 140 | sinon.assert.calledWith(manager.syncCookies, req.headers); 141 | expect(req.headers['x-wsabi-manager']).to.equal(manager.id); 142 | sinon.assert.calledWith(manager.server.inject, req); 143 | 144 | var res = { headers: { 'set-cookie': 'asdf' }}; 145 | manager.server.inject.yield(res); 146 | sinon.assert.calledWith(manager.updateCookies, res.headers); 147 | sinon.assert.calledWith(callback, res); 148 | }); 149 | }); 150 | 151 | describe('sticky and strip headers', function () { 152 | var handler; 153 | 154 | beforeEach(function () { 155 | handler = manager.handler = new EventEmitter(); 156 | config.sticky = ['x-forwarded-for']; 157 | config.strip = ['silly']; 158 | manager.handler.boot = sinon.stub(); 159 | manager.boot({}); 160 | }); 161 | 162 | it('adds sticky headers', function () { 163 | var req = { headers: { foo: 'bar' }, payload: {}, route: '/' }; 164 | handler.emit('request', req); 165 | expect(req.headers['x-forwarded-for']).to.equal('10.0.2.2'); 166 | }); 167 | 168 | it('prevents overwrite of sticky headers', function () { 169 | var req = { headers: { 'x-forwarded-for': 'lies!' }, payload: {}, route: '/' }; 170 | handler.emit('request', req); 171 | expect(req.headers['x-forwarded-for']).to.equal('10.0.2.2'); 172 | }); 173 | 174 | it('strips headers', function () { 175 | var req = { headers: { foo: 'bar' }, payload: {}, route: '/' }; 176 | var callback = sinon.stub(); 177 | handler.emit('request', req, callback); 178 | 179 | var res = { headers: { 'silly': 'asdf' }}; 180 | manager.server.inject.yield(res); 181 | expect(res.headers.silly).to.be.undefined; 182 | }) 183 | }); 184 | }); 185 | --------------------------------------------------------------------------------