├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TODO.md ├── example.js ├── package-lock.json ├── package.json ├── src ├── Extension.js ├── buffers.js ├── constants.js ├── extensions │ └── PerMessageDeflate.js ├── index.js ├── socket │ ├── Receiver.js │ ├── Sender.js │ └── index.js ├── util.js └── validate.js └── test ├── extensions.js ├── server.js └── validation.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "8.10.0" 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | "@babel/plugin-proposal-object-rest-spread" 14 | ] 15 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.{js,json}] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [.md] 17 | insert_final_newline = false 18 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "node": true, 5 | "es6": true, 6 | "jest": true 7 | }, 8 | "plugins": [ 9 | "node" 10 | ], 11 | "rules": { 12 | "node/no-missing-require": [ 13 | "error" 14 | ] 15 | }, 16 | "parserOptions": { 17 | "ecmaVersion": 6, 18 | "sourceType": "module", 19 | "ecmaFeatures": { 20 | "experimentalObjectRestSpread": true 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .vscode 4 | lib 5 | 6 | logs 7 | npm-debug.log* 8 | .eslintcache 9 | /coverage 10 | /dist 11 | /local 12 | /reports 13 | /node_modules 14 | .DS_Store 15 | Thumbs.db 16 | .idea 17 | *.sublime-project 18 | *.sublime-workspace 19 | 20 | # TODO Remove 21 | /old -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | - 8 5 | 6 | cache: 7 | directories: 8 | - node_modules 9 | 10 | notifications: 11 | email: false 12 | 13 | before_install: npm i -g npm@latest 14 | script: npm run test-ci -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | # [0.1.0](https://github.com/hugmanrique/turbo-ws/compare/v0.0.6...v0.1.0) (2018-03-11) 7 | 8 | 9 | ### Features 10 | 11 | * **broadcast:** Send frames to open sockets ([d1012db](https://github.com/hugmanrique/turbo-ws/commit/d1012db)) 12 | 13 | 14 | 15 | 16 | ## [0.0.6](https://github.com/hugmanrique/turbo-ws/compare/v0.0.5...v0.0.6) (2018-03-11) 17 | 18 | 19 | 20 | 21 | ## [0.0.5](https://github.com/hugmanrique/turbo-ws/compare/v0.0.4...v0.0.5) (2018-03-11) 22 | 23 | 24 | 25 | 26 | ## [0.0.4](https://github.com/hugmanrique/turbo-ws/compare/v0.0.3...v0.0.4) (2018-03-11) 27 | 28 | 29 | 30 | 31 | ## [0.0.3](https://github.com/hugmanrique/turbo-ws/compare/v0.0.2...v0.0.3) (2018-03-11) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Hugo Manrique 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :dash: turbo-ws 2 | 3 | [![npm][npm]][npm-url] 4 | [![node][node]][node-url] 5 | [![deps][deps]][deps-url] 6 | [![tests][tests]][tests-url] 7 | [![coverage][cover]][cover-url] 8 | [![license][license]][license-url] 9 | 10 | A blazing fast low-level WebSocket server based on [turbo-net](https://github.com/mafintosh/turbo-net) and the awesome [ws](https://github.com/websockets/ws) server library for Node.js 11 | 12 | ## Features 13 | 14 | * Supports thousands of concurrent connections with minimal CPU and RAM impact. 15 | * Binary and text frames are supported. That means you can directly send Node's `Buffer`s or `String`s if you prefer. 16 | * Built with reliability in mind. 17 | 18 | ## Getting Started 19 | 20 | Install turbo-ws using [`npm`](https://www.npmjs.com/): 21 | 22 | ```bash 23 | npm install --save @hugmanrique/turbo-ws 24 | ``` 25 | 26 | Or via [`yarn`](https://yarnpkg.com/en/package/@hugmanrique/turbo-ws): 27 | 28 | ```bash 29 | yarn add @hugmanrique/turbo-ws 30 | ``` 31 | 32 | The minimum supported Node version is `v8.10.0`. 33 | 34 | Let's get started by creating a WebSocket server: 35 | 36 | ```javascript 37 | import Server from '@hugmanrique/turbo-ws'; 38 | 39 | const server = new Server(); 40 | const port = 80; 41 | ``` 42 | 43 | Then, add a `'connection'` listener. The callback will contain a [`Connection`](https://github.com/mafintosh/turbo-net#connectiononconnect) and a [`Request`](https://github.com/mafintosh/turbo-http#requrl) object: 44 | 45 | ```javascript 46 | server.on('connection', (connection, req) => { 47 | const userAgent = req.getHeader('User-Agent'); 48 | 49 | console.log(`Using ${userAgent} browser`); 50 | connection.send('Hello world!'); 51 | }); 52 | ``` 53 | 54 | Finally call the `#listen(port)` method and run your Node app: 55 | 56 | ```javascript 57 | server.listen(port).then(() => { 58 | console.log(`Listening on *:${port}`); 59 | }); 60 | ``` 61 | 62 | ## Methods 63 | 64 | #### `connection.send(data)` 65 | 66 | Sends data to the client. Depending on the type of the passed object, it will send the data in one or multiple frames: 67 | 68 | * Strings get sent directly in one frame. 69 | * Objects get converted to strings through `JSON.stringify`. 70 | * Node's [Buffers](https://nodejs.org/api/buffer.html) get sent as binary data and may be sent in multiple frames. 71 | 72 | #### `connection.ping([payload])` 73 | 74 | Sends a ping frame that may contain a payload. The client must send a Pong frame with the same payload in response. Check the [`connection.on('pong')`](#connectiononpong) method for more details. 75 | 76 | #### `connection.close([callback])` 77 | 78 | Closes the connection. 79 | 80 | #### `server.broadcast(data)` 81 | 82 | Sends data to all the clients. Follows the same logic as [`connection.send(data)`](#connectionsenddata) 83 | 84 | #### `server.getConnections()` 85 | 86 | Get an unordered array containing the current active [connections](https://github.com/mafintosh/turbo-net#connectiononconnect). 87 | 88 | #### `server.close()` 89 | 90 | Closes the server. Returns a `Promise` that will get completed when all the connections are closed. 91 | 92 | ## Events 93 | 94 | Both the `Server` and the `Connection` are [EventEmitters](https://nodejs.org/api/events.html#events_class_eventemitter), so you can listen to these events: 95 | 96 | #### `server.on('connection', connection)` 97 | 98 | Emitted when a new connection is established. The callback arguments are `connection, request`, where the first is a turbo-net [Connection](https://github.com/mafintosh/turbo-net#connectiononconnect) and the later is a turbo-http [Request](https://github.com/mafintosh/turbo-http#requrl) object. Check out their docs to see what fields and methods are available. 99 | 100 | #### `server.on('close')` 101 | 102 | Emitted when the server has terminated all the WebSocket connections and it is going to die. 103 | 104 | #### `connection.on('text', string)` 105 | 106 | Emitted when the client sends some text data. 107 | 108 | #### `connection.on('binary', binaryStream)` 109 | 110 | Emitted when the client sends some buffered binary data. Returns a [BinaryStream](src/binaryStream.js), which is a wrapper for Node's [`stream.Readable`](https://nodejs.org/api/stream.html#stream_readable_streams). You can then add listeners to the stream: 111 | 112 | ```javascript 113 | const writable = fs.createWriteStream('file.txt'); 114 | 115 | connection.on('binary', stream => { 116 | stream.pipe(writable); 117 | 118 | stream.on('end', () => { 119 | console.log('Saved client logs to disk!'); 120 | }); 121 | }); 122 | ``` 123 | 124 | #### `connection.on('ping')` 125 | 126 | Emitted when the server received a [Ping frame](https://tools.ietf.org/html/rfc6455#section-5.5.2) from the client. 127 | 128 | #### `connection.on('pong')` 129 | 130 | Emitted when the server received a [Pong frame](https://tools.ietf.org/html/rfc6455#section-5.5.3) from the client. 131 | 132 | #### `connection.on('close')` 133 | 134 | Emitted when a connection is fully closed. No other events will be emitted after. 135 | 136 | # License 137 | 138 | [MIT](LICENSE) © [Hugo Manrique](https://hugmanrique.me) 139 | 140 | [npm]: https://img.shields.io/npm/v/@hugmanrique/turbo-ws.svg 141 | [npm-url]: https://npmjs.com/package/@hugmanrique/turbo-ws 142 | [node]: https://img.shields.io/node/v/@hugmanrique/turbo-ws.svg 143 | [node-url]: https://nodejs.org 144 | [deps]: https://img.shields.io/david/hugmanrique/turbo-ws.svg 145 | [deps-url]: https://david-dm.org/hugmanrique/turbo-ws 146 | [tests]: https://img.shields.io/travis/hugmanrique/turbo-ws/master.svg 147 | [tests-url]: https://travis-ci.org/hugmanrique/turbo-ws 148 | [license-url]: LICENSE 149 | [license]: https://img.shields.io/github/license/hugmanrique/turbo-ws.svg 150 | [cover]: https://img.shields.io/coveralls/hugmanrique/turbo-ws.svg 151 | [cover-url]: https://coveralls.io/r/hugmanrique/turbo-ws/ 152 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODOs 2 | 3 | * Send binary data in multiple frames 4 | * Add a buffer size limit to avoid DoS attacks 5 | * Clean up the frame system 6 | * Add coverage data 7 | * Add objective performance data to README 8 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | const Server = require('./dist/').default; 2 | 3 | const port = 5000; 4 | const server = new Server(); 5 | 6 | /* eslint-disable no-console */ 7 | 8 | server.listen(port).then(() => { 9 | console.log(`⚡ Listening on *:${port}`); 10 | }); 11 | 12 | server.on('connection', socket => { 13 | socket.send('message'); 14 | 15 | socket.on('text', message => { 16 | console.log(`Client says "${message}"`); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hugmanrique/turbo-ws", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "description": "Blazing fast low-level WebSocket server", 6 | "keywords": [ 7 | "turbo", 8 | "websocket", 9 | "server" 10 | ], 11 | "homepage": "https://github.com/hugmanrique/turbo-ws", 12 | "bugs": { 13 | "url": "https://github.com/hugmanrique/turbo-ws/issues" 14 | }, 15 | "author": { 16 | "name": "Hugo Manrique", 17 | "url": "https://hugmanrique.me", 18 | "email": "npm@hugmanrique.me" 19 | }, 20 | "files": [ 21 | "dist/", 22 | "README.md", 23 | "LICENSE" 24 | ], 25 | "main": "dist/index.js", 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/hugmanrique/turbo-ws" 29 | }, 30 | "scripts": { 31 | "prebuild": "npm run clean", 32 | "build": "babel src -d dist --ignore 'src/**/*.test.js'", 33 | "prepublish": "npm run prebuild && npm run build", 34 | "clean": "del-cli dist", 35 | "release": "standard-version", 36 | "watch": "npm run build -- -w", 37 | "test": "jest", 38 | "test-ci": "jest && cat ./coverage/lcov.info | coveralls" 39 | }, 40 | "engines": { 41 | "node": ">=8.10.0" 42 | }, 43 | "dependencies": { 44 | "@hugmanrique/ws-extensions": "0.0.2", 45 | "turbo-http": "^0.3.0", 46 | "utf-8-validate": "^4.0.0" 47 | }, 48 | "devDependencies": { 49 | "@babel/cli": "^7.0.0-beta.40", 50 | "@babel/core": "^7.0.0-beta.40", 51 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0-beta.40", 52 | "@babel/preset-env": "^7.0.0-beta.40", 53 | "babel-core": "^7.0.0-bridge.0", 54 | "babel-jest": "^22.4.1", 55 | "coveralls": "^3.0.0", 56 | "del-cli": "^1.1.0", 57 | "jest": "^22.4.2", 58 | "standard-version": "^4.3.0", 59 | "ws": "^5.0.0" 60 | }, 61 | "jest": { 62 | "testEnvironment": "node", 63 | "collectCoverage": true, 64 | "coverageDirectory": "coverage/", 65 | "testMatch": [ 66 | "**/test/*.js" 67 | ] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Extension.js: -------------------------------------------------------------------------------- 1 | import { parse, serialize } from '@hugmanrique/ws-extensions'; 2 | 3 | /* eslint-disable no-unused-vars */ 4 | 5 | export default class Extension { 6 | constructor(options = {}, maxPayload) { 7 | this.options = options; 8 | this.maxPayload = maxPayload; 9 | } 10 | 11 | /** 12 | * Get extension name 13 | */ 14 | static get name() {} 15 | 16 | /** 17 | * Accept an extension negotiation offer. 18 | * @param {Array} offers Extension negotiation offers. Contains parsed args for each offer. 19 | * @return {Object} Accepted params 20 | */ 21 | static accept(offers) {} 22 | 23 | processData(receiver, data, callback) {} 24 | } 25 | 26 | export function handleNegotiation(server, socket, req) { 27 | const { extensions, options: { maxPayload } } = server; 28 | 29 | if (!extensions.length) { 30 | return; 31 | } 32 | 33 | socket.extensions = new Map(); 34 | const { extensions: negotiated } = socket; 35 | 36 | try { 37 | const offers = parse(req.getHeader('Sec-WebSocket-Extensions')); 38 | 39 | for (const Extension of extensions) { 40 | const extName = Extension.name; 41 | const extOffers = getOfferParams(offers, extName); 42 | 43 | const accepted = Extension.accept(extOffers); 44 | 45 | if (!accepted) { 46 | continue; 47 | } 48 | 49 | const instance = new Extension(accepted, maxPayload); 50 | 51 | negotiated.set(extName, instance); 52 | } 53 | } catch (err) { 54 | return err; 55 | } 56 | } 57 | 58 | function getOfferParams(offers, extensionName) { 59 | return offers 60 | .filter(({ name }) => name === extensionName) 61 | .map(offer => offer.params); 62 | } 63 | 64 | /** 65 | * Builds the Sec-WebSocket-Extension header field value. 66 | * 67 | * @param {Object} extensions A Map containing extName -> instance entries. 68 | * @return {String} A string representing the given extension map. 69 | */ 70 | export function serializeExtensions(extensions) { 71 | let header = ''; 72 | 73 | for (const [name, { options }] of extensions) { 74 | header += serialize(name, options) + ', '; 75 | } 76 | 77 | if (!header) { 78 | return header; 79 | } 80 | 81 | return header.substring(0, header.length - 2); 82 | } 83 | -------------------------------------------------------------------------------- /src/buffers.js: -------------------------------------------------------------------------------- 1 | export function unmask(buffer, mask) { 2 | const { length } = buffer; 3 | 4 | for (let i = 0; i < length; i++) { 5 | buffer[i] ^= mask[i & 3]; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const magicValue = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; 2 | 3 | export const EMPTY_BUFFER = Buffer.alloc(0); 4 | 5 | export const opCodes = { 6 | CONTINUATION: 0x0, 7 | TEXT: 0x1, 8 | BINARY: 0x2, 9 | CLOSE: 0x8, 10 | PING: 0x9, 11 | PONG: 0xa 12 | }; 13 | 14 | export const states = { 15 | CONNECTING: 1, 16 | OPEN: 2, 17 | CLOSING: 3, 18 | CLOSED: 4 19 | }; 20 | 21 | // Allow 30 seconds to complete the close handshake 22 | export const CLOSE_TIMEOUT = 30 * 1000; 23 | 24 | /** 25 | * Maximum safe integer in JavaScript is 2^53 - 1. turbo-ws 26 | * returns an error if payload length is greater than this number. 27 | */ 28 | export const frameSizeLimit = Math.pow(2, 53 - 32) - 1; 29 | export const frameSizeMult = Math.pow(2, 32); 30 | -------------------------------------------------------------------------------- /src/extensions/PerMessageDeflate.js: -------------------------------------------------------------------------------- 1 | import Extension from '../Extension'; 2 | //import { states } from '../socket/Receiver'; 3 | 4 | export default class PerMessageDeflate extends Extension { 5 | constructor() { 6 | throw new Error('Not supported yet'); 7 | } 8 | 9 | static get name() { 10 | return 'permessage-deflate'; 11 | } 12 | 13 | /*static accept(offers) {} 14 | 15 | processData(/*receiver, data, callback) { 16 | throw new Error('Not supported yet'); 17 | 18 | if (this.compressed) { 19 | receiver.state = states.INFLATING; 20 | // TODO Decompress and call callback 21 | return true; 22 | } 23 | }*/ 24 | } 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Modification of the ws project. Copyright (c) 2011 Einar Otto Stangvik. 3 | * Available on https://github.com/websockets/ws/ 4 | * 5 | * This work is licensed under the terms of the MIT license. 6 | */ 7 | 8 | import { EventEmitter } from 'events'; 9 | import http from 'turbo-http'; 10 | 11 | import { 12 | statusCodes, 13 | asksForUpgrade, 14 | shouldHandleRequest, 15 | pathEquals, 16 | getUpgradeKey, 17 | addListeners, 18 | forwardEvent 19 | } from './util'; 20 | 21 | import WebSocket from './socket'; 22 | import { handleNegotiation, serializeExtensions } from './Extension'; 23 | 24 | import { EMPTY_BUFFER } from './constants'; 25 | 26 | export default class Server extends EventEmitter { 27 | constructor({ 28 | maxPayload = 100 * 1024 * 1024, 29 | extensions = [], 30 | server, 31 | host, 32 | port, 33 | path = '' 34 | } = {}) { 35 | super(); 36 | 37 | if (!server && (!host || !port)) { 38 | throw new TypeError( 39 | 'Either the "server" or the "host" and "port" options must be specified' 40 | ); 41 | } 42 | 43 | if (!server) { 44 | server = http.createServer(this.handleRequest); 45 | server.listen(port); 46 | } 47 | 48 | // TODO Document how to attach request callback for custom servers 49 | 50 | this.server = server; 51 | this.options = { path, maxPayload }; 52 | this.extensions = new Set(extensions); 53 | 54 | for (const extension of this.extensions) { 55 | extension.setup(maxPayload); 56 | } 57 | 58 | addListeners(server, { 59 | listening: forwardEvent('listening'), 60 | error: forwardEvent('error') 61 | }); 62 | } 63 | 64 | handleRequest(req, res) { 65 | const { socket } = req; 66 | 67 | // Handle premature socket errors 68 | socket.on('error', onSocketError); 69 | 70 | if (!asksForUpgrade(req)) { 71 | return this.askToUpgrade(res); 72 | } 73 | 74 | const version = req.getHeader('Sec-WebSocket-Version'); 75 | 76 | if (!shouldHandleRequest(this, req, version)) { 77 | return closeConnection(socket, res, 400); 78 | } 79 | 80 | const negotiationErr = handleNegotiation(this, socket, req); 81 | 82 | if (negotiationErr) { 83 | return closeConnection(socket, res, 400); 84 | } 85 | 86 | this.upgradeConnection(req, res); 87 | } 88 | 89 | askToUpgrade(res) { 90 | const body = statusCodes[426]; 91 | 92 | // Ask the client to upgrade its protocol 93 | res.statusCode = 426; 94 | res.setHeader('Content-Type', 'text/plain'); 95 | res.end(body); 96 | } 97 | 98 | upgradeConnection(req, res) { 99 | const { socket } = req; 100 | 101 | // Destroy socket if client already snt a FIN packet 102 | if (!socket.readable || !socket.writable) { 103 | return socket.destroy(); 104 | } 105 | 106 | const clientKey = req.getHeader('Sec-WebSocket-Key'); 107 | const key = getUpgradeKey(clientKey); 108 | 109 | res.statusCode = 101; 110 | 111 | res.setHeader('Upgrade', 'websocket'); 112 | res.setHeader('Connection', 'upgrade'); 113 | res.setHeader('Sec-WebSocket-Accept', key); 114 | 115 | const ws = new WebSocket(this.options.maxPayload); 116 | 117 | const { extensions } = socket; 118 | 119 | if (extensions) { 120 | const value = serializeExtensions(extensions); 121 | res.setHeader('Sec-WebSocket-Extensions', value); 122 | } 123 | 124 | this.emit('headers', res); 125 | 126 | // Finish the handshake but keep connection open 127 | res.end(EMPTY_BUFFER, 0); 128 | 129 | // Remove connection error listener 130 | socket.removeListener('error', onSocketError); 131 | 132 | ws.start(socket, extensions); 133 | this.emit('connection', ws, req); 134 | } 135 | 136 | // See if request should be handled by this server 137 | shouldHandle(req) { 138 | const { path } = this.options; 139 | 140 | return path === '' || pathEquals(path, req); 141 | } 142 | } 143 | 144 | function closeConnection(socket, res, code, message) { 145 | message = message || statusCodes[code]; 146 | 147 | res.statusCode = code; 148 | res.setHeader('Connection', 'close'); 149 | res.setHeader('Content-Type', 'text/plain'); 150 | 151 | res.end(message); 152 | 153 | socket.removeListener('error', onSocketError); 154 | socket.close(); 155 | } 156 | 157 | function onSocketError() { 158 | this.destroy(); 159 | } 160 | -------------------------------------------------------------------------------- /src/socket/Receiver.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Modification of the ws project. Copyright (c) 2011 Einar Otto Stangvik. 3 | * Available on https://github.com/websockets/ws/ 4 | * 5 | * This work is licensed under the terms of the MIT license. 6 | */ 7 | 8 | import { Writable } from 'stream'; 9 | import { isValidUtf8, isValidStatusCode } from '../validate'; 10 | import { unmask } from '../buffers'; 11 | import { 12 | opCodes, 13 | frameSizeLimit, 14 | frameSizeMult, 15 | EMPTY_BUFFER 16 | } from '../constants'; 17 | 18 | export const statusCodeKey = Symbol('status-code'); 19 | const stopLoopKey = Symbol('stop-loop'); 20 | 21 | export const states = { 22 | GET: 0, 23 | GET_PAYLOAD_LENGTH_16: 1, 24 | GET_PAYLOAD_LENGTH_64: 2, 25 | GET_MASK: 3, 26 | GET_DATA: 4, 27 | INFLATING: 5 28 | }; 29 | 30 | class Receiver extends Writable { 31 | constructor(extensions, maxPayload) { 32 | super(); 33 | 34 | this.buffers = []; 35 | this.bufferedBytes = 0; 36 | this.maxPayload = maxPayload | 0; 37 | 38 | this.payloadLength = 0; 39 | this.fragmented = 0; 40 | this.masked = false; 41 | this.fin = false; 42 | this.opCode = 0; 43 | 44 | this.totalPayloadLength = 0; 45 | this.messageLength = 0; 46 | this.fragments = new Set(); 47 | 48 | this.extensions = extensions; 49 | this.state = states.GET; 50 | this.loop = false; 51 | } 52 | 53 | consume(length) { 54 | this.bufferedBytes -= length; 55 | const buffer = this.buffers[0]; 56 | 57 | if (length === buffer.length) { 58 | return this.buffers.shift(); 59 | } else if (length < buffer.length) { 60 | this.buffers[0] = buffer.slice(length); 61 | 62 | return buffer.slice(0, length); 63 | } 64 | 65 | const dst = Buffer.allocUnsafe(length); 66 | 67 | do { 68 | if (length >= buffer.length) { 69 | this.buffers.shift().copy(dst, dst.length - length); 70 | } else { 71 | buffer.copy(dst, dst.length - length, 0, length); 72 | } 73 | 74 | length -= buffer.length; 75 | } while (length > 0); 76 | 77 | return dst; 78 | } 79 | 80 | initParseLoop(callback) { 81 | this.loop = true; 82 | 83 | try { 84 | while (this.loop) { 85 | switch (this.state) { 86 | case states.GET: 87 | this.getInfo(); 88 | break; 89 | case states.GET_PAYLOAD_LENGTH_16: 90 | this.getPayloadLength16(); 91 | break; 92 | case states.GET_PAYLOAD_LENGTH_64: 93 | this.getPayloadLength64(); 94 | break; 95 | case states.GET_MASK: 96 | this.getMask(); 97 | break; 98 | case states.GET_DATA: 99 | this.getData(callback); 100 | break; 101 | // Inflating state 102 | default: 103 | this.loop = false; 104 | return; 105 | } 106 | } 107 | } catch (err) { 108 | this.loop = false; 109 | callback(err); 110 | } 111 | } 112 | 113 | // Read first 2 bytes of frame 114 | getInfo() { 115 | if (this.bufferedBytes < 2) { 116 | this.loop = false; 117 | return; 118 | } 119 | 120 | const buffer = this.consume(2); 121 | 122 | if ((buffer[0] & 0x30) !== 0x0) { 123 | error('RSV2 and RSV3 must be clear', 1002); 124 | } 125 | 126 | const compressed = (buffer[0] & 0x40) === 0x40; 127 | 128 | if (compressed) { 129 | // && !decompressionEnabled 130 | error('RSV1 must be clear', 1002); 131 | } 132 | 133 | this.fin = (buffer[0] & 0x80) === 0x80; 134 | this.opCode = buffer[0] & 0xf; 135 | this.payloadLength = buffer[1] & 0x7f; 136 | 137 | if (this.opCode === opCodes.CONTINUATION) { 138 | if (compressed) { 139 | error('RSV1 must be clear', 1002); 140 | } 141 | 142 | if (!this.fragmented) { 143 | error('Invalid opCode 0', 1002); 144 | } 145 | 146 | this.opCode = this.fragmented; 147 | } else if (this.opCode === opCodes.TEXT || this.opCode === opCodes.BINARY) { 148 | if (this.fragmented) { 149 | error(`Invalid opCode ${this.opCode}`, 1002); 150 | } 151 | 152 | this.compressed = compressed; 153 | } else if (isControlOpCode(this.opCode)) { 154 | if (!this.fin) { 155 | error('FIN must be set', 1002); 156 | } 157 | 158 | if (compressed) { 159 | error('RSV1 must be clear', 1002); 160 | } 161 | 162 | if (this.payloadLength > 0x7d) { 163 | error(`Invalid payload length ${this.payloadLength}`, 1002); 164 | } 165 | } else { 166 | error(`Invalid opCode ${this.opCode}`, 1002); 167 | } 168 | 169 | if (!this.fin && !this.fragmented) { 170 | this.fragmented = this.opCode; 171 | } 172 | 173 | this.masked = (buffer[1] & 0x80) === 0x80; 174 | const length = this.payloadLength; 175 | 176 | if (length === 126) { 177 | this.state = states.GET_PAYLOAD_LENGTH_16; 178 | } else if (length === 127) { 179 | this.state = states.GET_PAYLOAD_LENGTH_64; 180 | } else { 181 | return this.haveLength(); 182 | } 183 | } 184 | 185 | getPayloadLength16() { 186 | if (this.bufferedBytes < 2) { 187 | this.loop = false; 188 | return; 189 | } 190 | 191 | this.payloadLength = this.consume(2).readUInt16BE(0); 192 | return this.haveLength(); 193 | } 194 | 195 | getPayloadLength64() { 196 | if (this.bufferedBytes < 8) { 197 | this.loop = false; 198 | return; 199 | } 200 | 201 | const buffer = this.consume(8); 202 | const num = buffer.readUInt32BE(0); 203 | 204 | if (num > frameSizeLimit) { 205 | error('Payload length > 2^53 - 1', 1009); 206 | } 207 | 208 | this.payloadLength = num * frameSizeMult + buffer.readUInt32BE(4); 209 | return this.haveLength(); 210 | } 211 | 212 | haveLength() { 213 | if (this.payloadLength && !isControlOpCode(this.opCode)) { 214 | this.totalPayloadLength += this.payloadLength; 215 | 216 | // Check max payload size 217 | if (this.maxPayload > 0 && this.totalPayloadLength > this.maxPayload) { 218 | error('Maximum payload size exceeded', 1009); 219 | } 220 | } 221 | 222 | this.state = this.masked ? states.GET_MASK : states.GET_DATA; 223 | } 224 | 225 | getMask() { 226 | if (this.bufferedBytes < 4) { 227 | this.loop = false; 228 | return; 229 | } 230 | 231 | this.mask = this.consume(4); 232 | this.state = states.GET_DATA; 233 | } 234 | 235 | getData(callback) { 236 | let data = EMPTY_BUFFER; 237 | 238 | if (this.payloadLength) { 239 | if (this.bufferedBytes < this.payloadLength) { 240 | this.loop = false; 241 | return; 242 | } 243 | 244 | data = this.consume(this.payloadLength); 245 | 246 | if (this.masked) { 247 | unmask(data, this.mask); 248 | } 249 | } 250 | 251 | if (isControlOpCode(this.opCode)) { 252 | return this.controlMessage(data, this.mask); 253 | } 254 | 255 | // Let extensions process the data 256 | for (const [, extension] of this.extensions) { 257 | const stopRead = extension.processData(this, data, callback); 258 | 259 | if (stopRead) { 260 | return; 261 | } 262 | } 263 | 264 | if (data.length) { 265 | this.messageLength = this.totalPayloadLength; 266 | this.fragments.add(data); 267 | } 268 | 269 | return this.dataMessage(); 270 | } 271 | 272 | dataMessage() { 273 | if (this.fin) { 274 | const { messageLength, fragments } = this; 275 | 276 | this.totalPayloadLength = 0; 277 | this.messageLength = 0; 278 | this.fragmented = 0; 279 | this.fragments.clear(); 280 | 281 | const data = toBuffer(fragments, messageLength); 282 | 283 | if (this.opCode === opCodes.BINARY) { 284 | this.emit('message', data); 285 | } else { 286 | validateUtf8(data, true); 287 | 288 | this.emit('message', data.toString()); 289 | } 290 | } 291 | 292 | this.state = states.GET_INFO; 293 | } 294 | 295 | controlMessage(data) { 296 | if (this.opCode === opCodes.CLOSE) { 297 | this.loop = false; 298 | 299 | if (!data.length) { 300 | this.emit('conclude', 1005); 301 | this.end(); 302 | } else if (data.length === 1) { 303 | error('Invalid payload length 1', 1002, false); 304 | } else { 305 | const code = data.readUInt16BE(0); 306 | 307 | if (!isValidStatusCode(code)) { 308 | error(`Invalid status code ${code}`, 1002, false); 309 | } 310 | 311 | const buffer = data.slice(2); 312 | 313 | validateUtf8(buffer); 314 | 315 | this.emit('conclude', code, buffer.toString()); 316 | this.end(); 317 | } 318 | 319 | return; 320 | } 321 | 322 | this.emit(this.opCode === opCodes.PING ? 'ping' : 'pong', data); 323 | this.state = states.GET_INFO; 324 | } 325 | } 326 | 327 | function validateUtf8(buffer, stopLoop) { 328 | if (!isValidUtf8(buffer)) { 329 | error('Invalid UTF-8 sequence', 1007, stopLoop, Error); 330 | } 331 | } 332 | 333 | function error(message, statusCode, stopLoop = true, Constructor = RangeError) { 334 | const err = new Constructor(message); 335 | err[statusCodeKey] = statusCode; 336 | err[stopLoopKey] = stopLoop; 337 | 338 | throw err; 339 | } 340 | 341 | function isControlOpCode(opCode) { 342 | return opCode > 0x7 && opCode < 0xb; 343 | } 344 | 345 | function toBuffer(fragments, messageLength) { 346 | if (fragments.length === 1) { 347 | // TODO Avoid creating an entire iterator to get the first value 348 | return fragments.values().next().value; 349 | } 350 | 351 | if (fragments.length > 1) { 352 | return Buffer.concat(fragments, messageLength); 353 | } 354 | 355 | return EMPTY_BUFFER; 356 | } 357 | 358 | export default Receiver; 359 | -------------------------------------------------------------------------------- /src/socket/Sender.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Modification of the ws project. Copyright (c) 2011 Einar Otto Stangvik. 3 | * Available on https://github.com/websockets/ws/ 4 | * 5 | * This work is licensed under the terms of the MIT license. 6 | */ 7 | 8 | import { EMPTY_BUFFER, opCodes } from '../constants'; 9 | 10 | /** 11 | * HyBi sender implementation 12 | * @see https://tools.ietf.org/html/rfc6455 13 | */ 14 | export default class Sender { 15 | /** 16 | * @param {Object} socket A turbo-net connection 17 | */ 18 | constructor(socket) { 19 | this.socket = socket; 20 | this.firstFragment = true; 21 | 22 | // TODO Implement compression 23 | this.compress = false; 24 | 25 | this.bufferedBytes = 0; 26 | this.deflating = false; 27 | this.queue = []; 28 | } 29 | 30 | /** 31 | * Sends a close message. 32 | * @param {(Number|undefined)} code The status code of the body 33 | * @param {String} data The message of the body 34 | * @param {Function} callback 35 | */ 36 | close(code, data, callback) { 37 | let buffer; 38 | 39 | if (!code) { 40 | buffer = EMPTY_BUFFER; 41 | } else if (typeof code !== 'number') { 42 | throw new TypeError('First argument must be a valid error code number'); 43 | } 44 | 45 | buffer = Buffer.allocUnsafe(2 + (data ? Buffer.byteLength(data) : 0)); 46 | buffer.writeUInt16BE(code, 0, true); 47 | 48 | if (data) { 49 | buffer.write(data, 2); 50 | } 51 | 52 | if (this.deflating) { 53 | this.queue([this._close, buffer, callback]); 54 | } else { 55 | this._close(buffer, callback); 56 | } 57 | } 58 | 59 | _close(data, callback) { 60 | this.sendFrame( 61 | frame(data, { 62 | fin: true, 63 | opCode: opCodes.CLOSE 64 | }), 65 | callback 66 | ); 67 | } 68 | 69 | /** 70 | * Sends a ping message. 71 | * @param {*} data The message to send 72 | * @param {Function} callback 73 | */ 74 | ping(data, callback) { 75 | if (!Buffer.isBuffer(data)) { 76 | data = Buffer.from(data); 77 | } 78 | 79 | if (this.deflating) { 80 | this.queue([this._ping, data, callback]); 81 | } else { 82 | this.ping(data, callback); 83 | } 84 | } 85 | 86 | _ping(data, callback) { 87 | this.sendFrame( 88 | frame(data, { 89 | fin: true, 90 | opCode: opCodes.PING 91 | }), 92 | callback 93 | ); 94 | } 95 | 96 | /** 97 | * Sends a pong message 98 | * @param {*} data The message to send 99 | * @param {Function} callback 100 | */ 101 | pong(data, callback) { 102 | if (!Buffer.isBuffer(data)) { 103 | data = Buffer.from(data); 104 | } 105 | 106 | if (this.deflating) { 107 | this.queue([this._pong, data, callback]); 108 | } else { 109 | this._pong(data, callback); 110 | } 111 | } 112 | 113 | _pong(data, callback) { 114 | this.sendFrame( 115 | frame(data, { 116 | fin: true, 117 | opCode: opCodes.PONG 118 | }), 119 | callback 120 | ); 121 | } 122 | 123 | /** 124 | * Sends a data message. 125 | * @param {*} data The message to send 126 | * @param {Object} options 127 | * @param {Boolean} options.compress Whether or not to compress data. 128 | * @param {Boolean} options.binary Whether data is binary or text. 129 | * @param {Boolean} options.fin Whether the fragment is the last one. 130 | * @param {Function} callback 131 | */ 132 | send(data, { compress, binary, fin }, callback) { 133 | let opCode = binary ? opCodes.BINARY : opCodes.TEXT; 134 | let rsv1 = compress; 135 | 136 | if (!Buffer.isBuffer(data)) { 137 | data = Buffer.from(data); 138 | } 139 | 140 | if (compress) { 141 | throw new Error('Unsupported operation, not implemented yet'); 142 | } 143 | 144 | if (this.firstFragment) { 145 | this.firstFragment = false; 146 | 147 | // TODO Implement compression limit 148 | 149 | this.compress = rsv1; 150 | } else { 151 | rsv1 = false; 152 | opCode = opCodes.CONTINUATION; 153 | } 154 | 155 | if (fin) { 156 | this.firstFragment = true; 157 | } 158 | 159 | if (compress) { 160 | // TODO 161 | } else { 162 | this.sendFrame( 163 | frame(data, { 164 | fin, 165 | rsv1: false, 166 | opCode 167 | }), 168 | callback 169 | ); 170 | } 171 | } 172 | 173 | /** 174 | * Executes queued send operations 175 | */ 176 | dequeue() { 177 | while (!this.deflating && this.queue.length) { 178 | const [fn, ...params] = this.queue.shift(); 179 | 180 | this.bufferedBytes = params[0].length; 181 | fn.apply(this, params); 182 | } 183 | } 184 | 185 | /** 186 | * Enqueues a send operation 187 | * @param {Array} params Send operation parameters. 188 | */ 189 | queue(params) { 190 | this.bufferedBytes += params[1].length; 191 | this.queue.push(params); 192 | } 193 | 194 | /** 195 | * Sends a frame 196 | * @param {Buffer[]} buffers The frame to send 197 | * @param {Function} callback 198 | */ 199 | sendFrame(buffers, callback) { 200 | const multiple = buffers.length === 2; 201 | 202 | // Attach callback to last write 203 | this.socket.write(buffers[0], !multiple && callback); 204 | this.socket.write(buffers[1], multiple && callback); 205 | } 206 | } 207 | 208 | /** 209 | * Frames a piece of data into multiple buffers. 210 | * @param {Buffer} data The data to frame. 211 | * @param {Number} options.opCode The opCode 212 | * @param {Boolean} options.fin Whether or not to set the FIN bit 213 | * @param {Boolean} options.rsv1 Whether or not to set the RSV1 bit 214 | * @return {Buffer[]} The framed data as a list of Buffer instances 215 | */ 216 | function frame(data, { fin, opCode, rsv1 }) { 217 | const merge = data.length < 1024; 218 | let offset = 2; 219 | let payloadLength = data.length; 220 | 221 | if (data.length >= 65536) { 222 | offset += 8; 223 | payloadLength = 127; 224 | } else if (data.length > 125) { 225 | offset += 2; 226 | payloadLength = 126; 227 | } 228 | 229 | const target = Buffer.allocUnsafe(merge ? data.length + offset : offset); 230 | 231 | target[0] = fin ? opCode | 0x80 : opCode; 232 | 233 | if (rsv1) { 234 | target[0] |= 0x40; 235 | } 236 | 237 | if (payloadLength === 126) { 238 | target.writeUInt16BE(data.length, 2, true); 239 | } else if (payloadLength === 127) { 240 | target.writeUInt32BE(0, 2, true); 241 | target.writeUInt32BE(data.length, 6, true); 242 | } 243 | 244 | target[1] = payloadLength; 245 | 246 | if (merge) { 247 | data.copy(target, offset); 248 | return [target]; 249 | } 250 | 251 | return [target, data]; 252 | } 253 | -------------------------------------------------------------------------------- /src/socket/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Modification of the ws project. Copyright (c) 2011 Einar Otto Stangvik. 3 | * Available on https://github.com/websockets/ws/ 4 | * 5 | * This work is licensed under the terms of the MIT license. 6 | */ 7 | 8 | import EventEmitter from 'events'; 9 | import Receiver, { statusCodeKey } from './Receiver'; 10 | import Sender from './Sender'; 11 | 12 | import { states, CLOSE_TIMEOUT, EMPTY_BUFFER } from '../constants'; 13 | 14 | export default class WebSocket extends EventEmitter { 15 | constructor(maxPayload) { 16 | super(); 17 | 18 | this.state = states.CONNECTING; 19 | this.receivedCloseFrame = false; 20 | this.maxPayload = maxPayload; 21 | } 22 | 23 | get extensions() { 24 | return this._extensions.values(); 25 | } 26 | 27 | start(socket, extensions) { 28 | const receiver = new Receiver(extensions, this.maxPayload); 29 | 30 | this.sender = new Sender(this.socket); 31 | this.receiver = receiver; 32 | this.socket = socket; 33 | this._extensions = extensions; 34 | 35 | // Attach all the internal listeners 36 | const onReceiverFinish = data => { 37 | this.emit('message', data); 38 | }; 39 | 40 | const onSocketData = this.addSocketListeners(onReceiverFinish); 41 | this.addReceiverListeners(onSocketData, onReceiverFinish); 42 | } 43 | 44 | addSocketListeners(receiverOnFinish) { 45 | const { socket, receiver } = this; 46 | 47 | const onData = buffer => { 48 | if (!receiver.write(buffer)) { 49 | socket.pause(); 50 | } 51 | }; 52 | 53 | const onEnd = () => { 54 | this.state = states.CLOSING; 55 | receiver.end(); 56 | this.end(); 57 | }; 58 | 59 | const onClose = () => { 60 | socket.removeListener('close', onClose); 61 | socket.removeListener('data', onData); 62 | socket.removeListener('end', onEnd); 63 | 64 | this.state = states.CLOSING; 65 | receiver.end(); 66 | 67 | clearTimeout(this.closeTimer); 68 | 69 | const { _writableState: { finished, errorEmitted } } = receiver; 70 | 71 | if (finished || errorEmitted) { 72 | this.emitClose(); 73 | } else { 74 | receiver.on('error', receiverOnFinish); 75 | receiver.on('finish', receiverOnFinish); 76 | } 77 | }; 78 | 79 | const onError = () => { 80 | socket.removeListener('error', onError); 81 | socket.on('error', () => {}); 82 | 83 | this.state = states.CLOSING; 84 | socket.destroy(); 85 | }; 86 | 87 | socket.on('close', onClose); 88 | socket.on('data', onData); 89 | socket.on('end', onEnd); 90 | socket.on('error', onError); 91 | 92 | return onData; 93 | } 94 | 95 | addReceiverListeners(socketOnData, onFinish) { 96 | const { socket, receiver } = this; 97 | 98 | receiver.on('conclude', (code, reason) => { 99 | socket.removeListener('data', socketOnData); 100 | socket.resume(); 101 | 102 | this.receivedCloseFrame = true; 103 | this.closeMessage = reason; 104 | this.closeCode = code; 105 | 106 | if (code === 1005) { 107 | this.close(); 108 | } else { 109 | this.close(code, reason); 110 | } 111 | }); 112 | 113 | receiver.on('drain', () => { 114 | socket.resume(); 115 | }); 116 | 117 | receiver.on('error', err => { 118 | this.state = states.CLOSING; 119 | 120 | this.closeCode = err[statusCodeKey]; 121 | this.emit('error', err); 122 | socket.destroy(); 123 | }); 124 | 125 | receiver.on('finish', onFinish); 126 | 127 | receiver.on('ping', data => { 128 | this.pong(data); 129 | this.emit('ping', data); 130 | }); 131 | 132 | receiver.on('pong', data => { 133 | this.emit('pong', data); 134 | }); 135 | } 136 | 137 | emitClose() { 138 | const { socket, closeCode, closeMessage } = this; 139 | this.state = states.CLOSED; 140 | 141 | if (socket) { 142 | for (const extension of this.extensions) { 143 | extension.cleanup(); 144 | } 145 | } 146 | 147 | this.emit('close', closeCode, closeMessage); 148 | } 149 | 150 | /** 151 | * Start a closing handshake 152 | * @param {Number} code Status code explaining why the connection is closing 153 | * @param {String} data Message explaining why the connection is closing 154 | */ 155 | close(code, data) { 156 | const { state, sender, socket, sentCloseFrame, receivedCloseFrame } = this; 157 | 158 | if (state === states.CLOSED) { 159 | return; 160 | } 161 | 162 | if (state === states.CONNECTING) { 163 | // TODO Fix 164 | return; 165 | /*return abortHandshake( 166 | this, 167 | 'WebSocket was closed before connection was established' 168 | );*/ 169 | } 170 | 171 | if (state === states.CLOSING) { 172 | if (sentCloseFrame && receivedCloseFrame) { 173 | return socket.end(); 174 | } 175 | } 176 | 177 | this.state = states.CLOSING; 178 | 179 | sender.close(code, data, err => { 180 | // Error handled by the socket 181 | if (err) return; 182 | 183 | this.sentCloseFrame = true; 184 | 185 | if (socket.writable) { 186 | if (this.receivedCloseFrame) { 187 | socket.end(); 188 | } 189 | } 190 | 191 | // Ensure connection is closed even if the handshake fails 192 | this.closeTimer = setTimeout(socket.destroy.bind(socket), CLOSE_TIMEOUT); 193 | }); 194 | } 195 | 196 | /** 197 | * Sends a ping frame 198 | * @param {*} data The data to send 199 | * @param {Function} callback Executed when the ping is sent 200 | */ 201 | ping(data, callback) { 202 | if (!this._verifyOpen(callback)) { 203 | return; 204 | } 205 | 206 | this.sender.ping(data || EMPTY_BUFFER, callback); 207 | } 208 | 209 | /** 210 | * Sends a pong frame 211 | * @param {*} data The data to send 212 | * @param {Function} callback Execute when the pong is sent 213 | */ 214 | pong(data, callback) { 215 | if (!this._verifyOpen(callback)) { 216 | return; 217 | } 218 | 219 | this.sender.pong(data, callback); 220 | } 221 | 222 | /** 223 | * Sends a data message 224 | * @param {*} data The message to send 225 | * @param {Object} options Options object 226 | * @param {Boolean} options.compress Whether or not to compress data. 227 | * @param {Boolean} options.binary Whether data is binary or text. 228 | * @param {Boolean} options.fin Whether the fragment is the last one. 229 | * @param {Function} callback Executed when data is written out. 230 | */ 231 | send(data, options = {}, callback) { 232 | if (typeof options === 'function') { 233 | callback = options; 234 | options = {}; 235 | } 236 | 237 | if (!this._verifyOpen(callback)) { 238 | return; 239 | } 240 | 241 | if (options.compress) { 242 | throw new Error('Unsupported operation: not implemented yet'); 243 | } 244 | 245 | this.sender.send(data || EMPTY_BUFFER, { 246 | binary: typeof data !== 'string', 247 | fin: true, 248 | ...options 249 | }); 250 | } 251 | 252 | _verifyOpen(callback) { 253 | const { state } = this; 254 | 255 | if (state === states.OPEN) { 256 | return true; 257 | } 258 | 259 | const err = new Error( 260 | `WebSocket connection is not open, state is ${state}` 261 | ); 262 | 263 | if (callback) { 264 | callback(err); 265 | return; 266 | } 267 | 268 | throw err; 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import { parse } from 'url'; 3 | import { magicValue } from './constants'; 4 | 5 | import statusCodes from 'turbo-http/http-status'; 6 | export { statusCodes }; 7 | 8 | // Upgrade utils 9 | export function asksForUpgrade(req) { 10 | return ( 11 | req.method === 'GET' && 12 | req.getHeader('Upgrade').toLowerCase() === 'websocket' 13 | ); 14 | } 15 | 16 | export function shouldHandleRequest(server, req, version) { 17 | return ( 18 | isValidVersion(version) && server.shouldHandle(req) // TODO Create version func 19 | ); 20 | } 21 | 22 | function isValidVersion(version) { 23 | return version === 8 || version === 13; 24 | } 25 | 26 | export function pathEquals(path, req) { 27 | return parse(req.url).pathname === path; 28 | } 29 | 30 | export function getUpgradeKey(clientKey) { 31 | return crypto 32 | .createHash('sha1') 33 | .update(`${clientKey}${magicValue}`, 'binary') 34 | .digest('base64'); 35 | } 36 | 37 | // EventEmitter utils 38 | 39 | export function addListeners(server, events) { 40 | const eventNames = Object.keys(events); 41 | 42 | for (const event of eventNames) { 43 | server.on(event, events[event]); 44 | } 45 | 46 | // Return anonymous function to remove all the added listeners when called 47 | return function() { 48 | for (const event of eventNames) { 49 | server.removeListener(event, events[event]); 50 | } 51 | }; 52 | } 53 | 54 | export function forwardEvent(server, eventName) { 55 | return server.emit.bind(server, eventName); 56 | } 57 | -------------------------------------------------------------------------------- /src/validate.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Modification of the ws project. Copyright (c) 2011 Einar Otto Stangvik. 3 | * Available on https://github.com/websockets/ws/ 4 | * 5 | * This work is licensed under the terms of the MIT license. 6 | */ 7 | 8 | import isValidUtf8 from 'utf-8-validate'; 9 | 10 | export { isValidUtf8 }; 11 | 12 | /** 13 | * Check if a status code is allowed in a CLOSE frame 14 | */ 15 | export function isValidStatusCode(code) { 16 | return ( 17 | (code >= 1000 && 18 | code <= 1013 && 19 | code !== 1004 && 20 | code !== 1005 && 21 | code !== 1006) || 22 | (code >= 3000 && code <= 4999) 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /test/extensions.js: -------------------------------------------------------------------------------- 1 | import Extension, { 2 | handleNegotiation, 3 | serializeExtensions 4 | } from '../src/Extension'; 5 | 6 | class DummyExtension extends Extension { 7 | static get name() { 8 | return 'Dummy'; 9 | } 10 | 11 | static accept(offers) { 12 | // Always return the first one 13 | return offers[0]; 14 | } 15 | } 16 | 17 | const mockServer = { 18 | options: { 19 | maxPayload: 100 * 1024 * 1024 20 | }, 21 | extensions: [] 22 | }; 23 | 24 | function buildReq(returnOffer) { 25 | return { 26 | getHeader() { 27 | return returnOffer; 28 | } 29 | }; 30 | } 31 | 32 | describe('extensions', () => { 33 | describe('negotiation', () => { 34 | it("should skip extension check if server doesn't have extensions", () => { 35 | const socket = {}; 36 | 37 | handleNegotiation(mockServer, socket, buildReq('Dummy')); 38 | 39 | expect(socket.extensions).toBeUndefined(); 40 | }); 41 | 42 | it('should handle basic negotiation', () => { 43 | mockServer.extensions = [DummyExtension]; 44 | const socket = {}; 45 | 46 | const err = handleNegotiation(mockServer, socket, buildReq('Dummy')); 47 | 48 | expect(err).toBeUndefined(); 49 | expect(socket.extensions).toBeDefined(); 50 | expect(socket.extensions.has('Dummy')).toBe(true); 51 | 52 | const instance = socket.extensions.get('Dummy'); 53 | expect(instance.constructor).toBe(DummyExtension); 54 | expect(instance.maxPayload).toBe(mockServer.options.maxPayload); 55 | }); 56 | 57 | it('should handle multiple offers', () => { 58 | const socket = {}; 59 | 60 | handleNegotiation(mockServer, socket, buildReq('Dummy; value=1, Dummy')); 61 | 62 | expect(socket.extensions.has('Dummy')).toBe(true); 63 | 64 | const instance = socket.extensions.get('Dummy'); 65 | expect(instance.options).toEqual({ value: 1 }); 66 | }); 67 | 68 | it('should ignore not supported offer', () => { 69 | const socket = {}; 70 | 71 | handleNegotiation(mockServer, socket, buildReq('NotSupported')); 72 | 73 | expect(socket.extensions.has('Dummy2')).toBe(false); 74 | }); 75 | }); 76 | 77 | describe('serialization', () => { 78 | it('should serialize single extension', () => { 79 | expect(serializeWrap(['Dummy', new DummyExtension()])).toBe('Dummy'); 80 | }); 81 | 82 | it('should serialize extension with params', () => { 83 | const ext = new DummyExtension({ value: 1 }); 84 | 85 | expect(serializeWrap(['Dummy', ext])).toBe('Dummy; value=1'); 86 | }); 87 | 88 | it('should serialize multiple extensions', () => { 89 | expect( 90 | serializeWrap( 91 | ['Dummy', new DummyExtension()], 92 | ['Dummy2', new DummyExtension()] 93 | ) 94 | ).toBe('Dummy, Dummy2'); 95 | }); 96 | 97 | it('should serialize multiple extensions with params', () => { 98 | const ext = new DummyExtension({ value: 'str' }); 99 | 100 | expect( 101 | serializeWrap(['Dummy', ext], ['Dummy2', new DummyExtension()]) 102 | ).toBe('Dummy; value=str, Dummy2'); 103 | }); 104 | }); 105 | }); 106 | 107 | function serializeWrap() { 108 | return serializeExtensions(new Map(arguments)); 109 | } 110 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugmanrique/turbo-ws/16123cacdbd3bfa362452ed08f7aaa2efc10c65d/test/server.js -------------------------------------------------------------------------------- /test/validation.js: -------------------------------------------------------------------------------- 1 | import { isValidStatusCode } from '../src/validate'; 2 | 3 | describe('validation', () => { 4 | test('close frame status codes', () => { 5 | const closeCodes = [ 6 | 1000, 7 | 1001, 8 | 1002, 9 | 1003, 10 | 1007, 11 | 1008, 12 | 1009, 13 | 1010, 14 | 1011, 15 | 1012, 16 | 1013, 17 | 3000, 18 | 3604, 19 | 4505, 20 | 4999 21 | ]; 22 | 23 | const value = reduceBoolArray(closeCodes.map(isValidStatusCode)); 24 | 25 | expect(value).toBe(true); 26 | }); 27 | 28 | test('invalid close frame status code fail', () => { 29 | const invalidCodes = [ 30 | 0, 31 | 52, 32 | 484, 33 | 999, 34 | 1014, 35 | 1015, 36 | 2000, 37 | 2305, 38 | 2752, 39 | 2998, 40 | 5000, 41 | 7505 42 | ]; 43 | 44 | const value = reduceBoolArray( 45 | invalidCodes.map(code => !isValidStatusCode(code)) 46 | ); 47 | 48 | expect(value).toBe(true); 49 | }); 50 | }); 51 | 52 | function reduceBoolArray(array) { 53 | return array.reduce((prev, current) => prev && current); 54 | } 55 | --------------------------------------------------------------------------------