├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── appveyor.yml ├── clusterPubSub.js ├── config.json ├── config ├── index.js ├── socket.js └── wsKernel.js ├── doc ├── external.adoc ├── internal.adoc └── protocol.md ├── instructions.js ├── instructions.md ├── japaFile.js ├── package.json ├── providers └── WsProvider.js ├── src ├── Channel │ ├── Manager.js │ └── index.js ├── ClusterHop │ ├── index.js │ ├── receiver.js │ └── sender.js ├── Connection │ └── index.js ├── Context │ └── index.js ├── JsonEncoder │ └── index.js ├── Middleware │ └── index.js ├── Socket │ └── index.js └── Ws │ └── index.js └── test ├── functional ├── setup.js └── ws.spec.js ├── helpers ├── Request.js └── index.js └── unit ├── channel.spec.js ├── cluster-receiver.spec.js ├── connection.spec.js ├── json-encoder.spec.js ├── socket.spec.js └── ws.spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.json] 16 | insert_final_newline = ignore 17 | 18 | [**.min.js] 19 | indent_style = ignore 20 | insert_final_newline = ignore 21 | 22 | [MakeFile] 23 | indent_style = tab 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report identified bugs 4 | --- 5 | 6 | 7 | 8 | ## Prerequisites 9 | 10 | We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. 11 | 12 | - Lots of raised issues are directly not bugs but instead are design decisions taken by us. 13 | - Make use of our [forum](https://forum.adonisjs.com/), or [discord server](https://discord.me/adonisjs), if you are not sure that you are reporting a bug. 14 | - Ensure the issue isn't already reported. 15 | - Ensure you are reporting the bug in the correct repo. 16 | 17 | *Delete the above section and the instructions in the sections below before submitting* 18 | 19 | ## Package version 20 | 21 | 22 | ## Node.js and npm version 23 | 24 | 25 | ## Sample Code (to reproduce the issue) 26 | 27 | 28 | ## BONUS (a sample repo to reproduce the issue) 29 | 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Propose changes for adding a new feature 4 | --- 5 | 6 | 7 | 8 | ## Prerequisites 9 | 10 | We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. 11 | 12 | ## Consider an RFC 13 | 14 | Please create an [RFC](https://github.com/adonisjs/rfcs) instead, if 15 | 16 | - Feature introduces a breaking change 17 | - Demands lots of time and changes in the current code base. 18 | 19 | *Delete the above section and the instructions in the sections below before submitting* 20 | 21 | ## Why this feature is required (specific use-cases will be appreciated)? 22 | 23 | 24 | ## Have you tried any other work arounds? 25 | 26 | 27 | ## Are you willing to work on it with little guidance? 28 | 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Proposed changes 4 | 5 | Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. 6 | 7 | ## Types of changes 8 | 9 | What types of changes does your code introduce? 10 | 11 | _Put an `x` in the boxes that apply_ 12 | 13 | - [ ] Bugfix (non-breaking change which fixes an issue) 14 | - [ ] New feature (non-breaking change which adds functionality) 15 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 16 | 17 | ## Checklist 18 | 19 | _Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ 20 | 21 | - [ ] I have read the [CONTRIBUTING](https://github.com/adonisjs/adonis-websocket/CONTRIBUTING.md) doc 22 | - [ ] Lint and unit tests pass locally with my changes 23 | - [ ] I have added tests that prove my fix is effective or that my feature works. 24 | - [ ] I have added necessary documentation (if appropriate) 25 | 26 | ## Further comments 27 | 28 | If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | .DS_Store 4 | npm-debug.log 5 | .idea 6 | out 7 | .nyc_output 8 | .DS_STORE 9 | .vscode/ 10 | *.sublime-project 11 | *.sublime-workspace 12 | *.log 13 | build 14 | dist 15 | yarn.lock 16 | shrinkwrap.yaml 17 | package-lock.json 18 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | .DS_Store 4 | npm-debug.log 5 | test 6 | .travis.yml 7 | .editorconfig 8 | benchmarks 9 | .idea 10 | bin 11 | out 12 | .nyc_output 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | - 8.0.0 5 | sudo: false 6 | install: 7 | - npm install 8 | after_script: 9 | - npm run coverage 10 | notifications: 11 | slack: 12 | secure: >- 13 | m91zkX2cLVDRDMBAUnR1d+hbZqtSHXLkuPencHadhJ3C3wm53Box8U25co/goAmjnW5HNJ1SMSIg+DojtgDhqTbReSh5gSbU0uU8YaF8smbvmUv3b2Q8PRCA7f6hQiea+a8+jAb7BOvwh66dV4Al/1DJ2b4tCjPuVuxQ96Wll7Pnj1S7yW/Hb8fQlr9wc+INXUZOe8erFin+508r5h1L4Xv0N5ZmNw+Gqvn2kPJD8f/YBPpx0AeZdDssTL0IOcol1+cDtDzMw5PAkGnqwamtxhnsw+i8OW4avFt1GrRNlz3eci5Cb3NQGjHxJf+JIALvBeSqkOEFJIFGqwAXMctJ9q8/7XyXk7jVFUg5+0Z74HIkBwdtLwi/BTyXMZAgsnDjndmR9HsuBP7OSTJF5/V7HCJZAaO9shEgS8DwR78owv9Fr5er5m9IMI+EgSH3qtb8iuuQaPtflbk+cPD3nmYbDqmPwkSCXcXRfq3IxdcV9hkiaAw52AIqqhnAXJWZfL6+Ct32i2mtSaov9FYtp/G0xb4tjrUAsDUd/AGmMJNEBVoHtP7mKjrVQ35cEtFwJr/8SmZxGvOaJXPaLs43dhXKa2tAGl11wF02d+Rz1HhbOoq9pJvJuqkLAVvRdBHUJrB4/hnTta5B0W5pe3mIgLw3AmOpk+s/H4hAP4Hp0gOWlPA= 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.12](https://github.com/adonisjs/adonis-websocket/compare/v1.0.11...v1.0.12) (2019-09-04) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * mark request getter as singleton ([a8a8ef0](https://github.com/adonisjs/adonis-websocket/commit/a8a8ef0)) 7 | 8 | 9 | 10 | 11 | ## [1.0.11](https://github.com/adonisjs/adonis-websocket/compare/v1.0.10...v1.0.11) (2018-07-17) 12 | 13 | 14 | ### Bug Fixes 15 | 16 | * **server:** allow trailing slashes in request url ([fb61e56](https://github.com/adonisjs/adonis-websocket/commit/fb61e56)), closes [#57](https://github.com/adonisjs/adonis-websocket/issues/57) 17 | 18 | 19 | 20 | 21 | ## [1.0.10](https://github.com/adonisjs/adonis-websocket/compare/v1.0.9...v1.0.10) (2018-06-02) 22 | 23 | 24 | 25 | 26 | ## [1.0.9](https://github.com/adonisjs/adonis-websocket/compare/v1.0.8...v1.0.9) (2018-04-06) 27 | 28 | 29 | ### Features 30 | 31 | * **channel:** add option to broadcast from channel directly ([5a3464c](https://github.com/adonisjs/adonis-websocket/commit/5a3464c)) 32 | * **channel:** expose get & getChannel methods to get channel instance ([6d23355](https://github.com/adonisjs/adonis-websocket/commit/6d23355)) 33 | 34 | 35 | 36 | 37 | ## [1.0.9](https://github.com/adonisjs/adonis-websocket/compare/v1.0.8...v1.0.9) (2018-04-06) 38 | 39 | 40 | ### Features 41 | 42 | * **channel:** add option to broadcast from channel directly ([5a3464c](https://github.com/adonisjs/adonis-websocket/commit/5a3464c)) 43 | * **channel:** expose get & getChannel methods to get channel instance ([6d23355](https://github.com/adonisjs/adonis-websocket/commit/6d23355)) 44 | 45 | 46 | 47 | 48 | ## [1.0.8](https://github.com/adonisjs/adonis-websocket/compare/v1.0.7...v1.0.8) (2018-03-21) 49 | 50 | 51 | 52 | 53 | ## [1.0.7](https://github.com/adonisjs/adonis-websocket/compare/v1.0.6...v1.0.7) (2018-03-18) 54 | 55 | 56 | 57 | 58 | ## 1.0.6 (2018-03-16) 59 | 60 | 61 | ### Features 62 | 63 | * **channel:** add option to define channel controller ([fa792d6](https://github.com/adonisjs/adonis-websocket/commit/fa792d6)) 64 | * **cluster:** add cluster pub/sub file to deliver messages ([6a4c52a](https://github.com/adonisjs/adonis-websocket/commit/6a4c52a)) 65 | * **middleware:** add support for Adonis style middleware ([f0eaf3d](https://github.com/adonisjs/adonis-websocket/commit/f0eaf3d)) 66 | 67 | 68 | 69 | 70 | ## 1.0.5 (2018-03-16) 71 | 72 | 73 | ### Features 74 | 75 | * rewrite ([a8b7d15](https://github.com/adonisjs/adonis-websocket/commit/a8b7d15)) 76 | * **config:** add sample config file ([4bf58c5](https://github.com/adonisjs/adonis-websocket/commit/4bf58c5)) 77 | * **encoder:** use json encoder by default ([01aa591](https://github.com/adonisjs/adonis-websocket/commit/01aa591)) 78 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | AdonisJs is a community driven project. You are free to contribute in any of the following ways. 4 | 5 | - [Coding style](coding-style) 6 | - [Fix bugs by creating PR's](fix-bugs-by-creating-prs) 7 | - [Share an RFC for new features or big changes](share-an-rfc-for-new-features-or-big-changes) 8 | - [Report security issues](report-security-issues) 9 | - [Be a part of the community](be-a-part-of-community) 10 | 11 | ## Coding style 12 | 13 | Majority of AdonisJs core packages are written in Typescript. Having a brief knowledge of Typescript is required to contribute to the core. [Learn more](https://adonisjs.com/coding-style) about the same. 14 | 15 | ## Fix bugs by creating PR's 16 | 17 | We appreciate every time you report a bug in the framework or related libraries. However, taking time to submit a PR can help us in fixing bugs quickly and ensure a healthy and stable eco-system. 18 | 19 | Go through the following points, before creating a new PR. 20 | 21 | 1. Create an issue discussing the bug or short-coming in the framework. 22 | 2. Once approved, go ahead and fork the REPO. 23 | 3. Make sure to start from the `develop`, since this is the upto date branch. 24 | 4. Make sure to keep commits small and relevant. 25 | 5. We follow [conventional-commits](https://github.com/conventional-changelog/conventional-changelog) to structure our commit messages. Instead of running `git commit`, you must run `npm commit`, which will show you prompts to create a valid commit message. 26 | 6. Once done with all the changes, create a PR against the `develop` branch. 27 | 28 | ## Share an RFC for new features or big changes 29 | 30 | Sharing PR's for small changes works great. However, when contributing big features to the framework, it is required to go through the RFC process. 31 | 32 | ### What is an RFC? 33 | 34 | RFC stands for **Request for Commits**, a standard process followed by many other frameworks including [Ember](https://github.com/emberjs/rfcs), [yarn](https://github.com/yarnpkg/rfcs) and [rust](https://github.com/rust-lang/rfcs). 35 | 36 | In brief, RFC process allows you to talk about the changes with everyone in the community and get a view of the core team before dedicating your time to work on the feature. 37 | 38 | The RFC proposals are created as issues on [adonisjs/rfcs](https://github.com/adonisjs/rfcs) repo. Make sure to read the README to learn about the process in depth. 39 | 40 | ## Report security issues 41 | 42 | All of the security issues, must be reported via [email](mailto:virk@adonisjs.com) and not using any of the public channels. [Learn more](https://adonisjs.com/security) about the security policy 43 | 44 | ## Be a part of community 45 | 46 | We welcome you to participate in the [forum](https://forum.adonisjs.com/) and the AdonisJs [discord server](https://discord.me/adonisjs). You are free to ask your questions and share your work or contributions made to AdonisJs eco-system. 47 | 48 | We follow a strict [Code of Conduct](https://adonisjs.com/community-guidelines) to make sure everyone is respectful to each other. 49 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2018 thetutlage, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Readme to follow soon 2 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: Stable 4 | - nodejs_version: 8.0.0 5 | init: git config --global core.autocrlf true 6 | install: 7 | - ps: 'Install-Product node $env:nodejs_version' 8 | - npm install 9 | test_script: 10 | - node --version 11 | - npm --version 12 | - 'npm run test:win' 13 | build: 'off' 14 | clone_depth: 1 15 | matrix: 16 | fast_finish: true 17 | -------------------------------------------------------------------------------- /clusterPubSub.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const cluster = require('cluster') 13 | const debug = require('debug')('adonis:websocket') 14 | 15 | /** 16 | * Calls a callback by looping over cluster workers 17 | * 18 | * @method worketIterator 19 | * 20 | * @param {Function} callback 21 | * 22 | * @return {void} 23 | */ 24 | function workerIterator (callback) { 25 | Object.keys(cluster.workers).forEach((index) => callback(cluster.workers[index])) 26 | } 27 | 28 | /** 29 | * Delivers the message to all the cluster workers, apart from the 30 | * one that sends the message 31 | * 32 | * @method deliverMessage 33 | * 34 | * @param {String} message 35 | * 36 | * @return {void} 37 | */ 38 | function deliverMessage (message) { 39 | workerIterator((worker) => { 40 | if (this.process.pid === worker.process.pid) { 41 | return 42 | } 43 | debug('delivering message to %s', worker.process.pid) 44 | worker.send(message) 45 | }) 46 | } 47 | 48 | module.exports = function () { 49 | if (!cluster.isMaster) { 50 | throw new Error('clusterPubSub can be only be used with cluster master') 51 | } 52 | workerIterator((worker) => worker.on('message', deliverMessage)) 53 | } 54 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "core": true, 3 | "ts": false, 4 | "license": "MIT", 5 | "services": [ 6 | "travis", 7 | "appveyor", 8 | "coveralls" 9 | ], 10 | "appveyorUsername": "thetutlage", 11 | "minNodeVersion": "8.0.0" 12 | } 13 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* 4 | |-------------------------------------------------------------------------- 5 | | Websocket Config 6 | |-------------------------------------------------------------------------- 7 | | 8 | | Used by AdonisJs websocket server 9 | | 10 | */ 11 | module.exports = { 12 | /* 13 | |-------------------------------------------------------------------------- 14 | | Path 15 | |-------------------------------------------------------------------------- 16 | | 17 | | The base path on which the websocket server will accept connections. 18 | | 19 | */ 20 | path: '/adonis-ws', 21 | 22 | /* 23 | |-------------------------------------------------------------------------- 24 | | Server Interval 25 | |-------------------------------------------------------------------------- 26 | | 27 | | This interval is used to create a timer for identifying dead client 28 | | connections. 29 | | 30 | */ 31 | serverInterval: 30000, 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | Server Attempts 36 | |-------------------------------------------------------------------------- 37 | | 38 | | Server attempts are used with serverInterval to identify dead client 39 | | connections. A total of `serverAttempts` attmepts after `serverInterval` 40 | | will be made before terminating the client connection. 41 | | 42 | */ 43 | serverAttempts: 3, 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | Client Interval 48 | |-------------------------------------------------------------------------- 49 | | 50 | | This interval is used by client to send ping frames to the server. 51 | | 52 | */ 53 | clientInterval: 25000, 54 | 55 | /* 56 | |-------------------------------------------------------------------------- 57 | | Client Attempts 58 | |-------------------------------------------------------------------------- 59 | | 60 | | Clients attempts are number of times the client will attempt to send the 61 | | ping, without receiving a pong from the server. After attempts have 62 | | been elapsed, the client will consider server as dead. 63 | | 64 | */ 65 | clientAttempts: 3 66 | } 67 | -------------------------------------------------------------------------------- /config/socket.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* 4 | |-------------------------------------------------------------------------- 5 | | Websocket 6 | |-------------------------------------------------------------------------- 7 | | 8 | | This file is used to register websocket channels and start the Ws server. 9 | | Learn more about same in the official documentation. 10 | | https://adonisjs.com/docs/websocket 11 | | 12 | | For middleware, do check `wsKernel.js` file. 13 | | 14 | */ 15 | 16 | const Ws = use('Ws') 17 | 18 | Ws.channel('chat', ({ socket }) => { 19 | console.log('user joined with %s socket id', socket.id) 20 | }) 21 | -------------------------------------------------------------------------------- /config/wsKernel.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Ws = use('Ws') 4 | 5 | /* 6 | |-------------------------------------------------------------------------- 7 | | Global middleware 8 | |-------------------------------------------------------------------------- 9 | | 10 | | Global middleware are executed on each Websocket channel subscription. 11 | | 12 | */ 13 | const globalMiddleware = [ 14 | ] 15 | 16 | 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | Named middleware 20 | |-------------------------------------------------------------------------- 21 | | 22 | | Named middleware are defined as key/value pairs. Later you can use the 23 | | keys to run selected middleware on a given channel. 24 | | 25 | | // define 26 | | { 27 | | auth: 'Adonis/Middleware/Auth' 28 | | } 29 | | 30 | | // use 31 | | Ws.channel('chat', 'ChatController').middleware(['auth']) 32 | */ 33 | const namedMiddleware = { 34 | } 35 | 36 | 37 | Ws 38 | .registerGlobal(globalMiddleware) 39 | .registerNamed(namedMiddleware) 40 | -------------------------------------------------------------------------------- /doc/external.adoc: -------------------------------------------------------------------------------- 1 | = Websocket 2 | 3 | === Always rely on websocket connection and do not make use of long-polling etc. 4 | This makes it easier to scale using Node.js cluster module and distribute load on multiple servers, without implimenting sticky sessions. 5 | 6 | Whereas, with long-polling, we need to make sure right request goes to right node when upgrading to Websocket connection, which is impossible with cluster module. 7 | 8 | === Middleware on handshake 9 | All of the middleware will be executed on handshake, which keeps sockets layer clean, since once you are connected, you are safe. 10 | 11 | === Open packet 12 | Server will emit open packet, sharing important info like `pingInterval`, `pingTimeout` etc. 13 | 14 | === Ping/Pong 15 | Server and client both needs to play ping/pong. If no ping received from client during defined `pingInterval`, the connection will be closed from the server. 16 | 17 | === Client closing connection 18 | Client can close connection by sending `close` event, optionally with `code` and `status` message. Client will receive the `close` event back in return. 19 | -------------------------------------------------------------------------------- /doc/internal.adoc: -------------------------------------------------------------------------------- 1 | = Websocket 2 | 3 | === Detecting memory leaks 4 | Ensure there are no memory leaks. 5 | 6 | 1. Always drop sockets when they have been closed. 7 | 2. Ping/pong to drop inactive sockets. 8 | 3. Do not store too much data. 9 | 10 | === Handle chrome disconnect bug 11 | Chrome disconnect is not graceful so handle the error code and ignore it. 12 | 13 | === Add proper debugging 14 | Proper debugging is super useful 15 | 16 | === Data encoders 17 | Allow encoders to be configurable. Useful when someone is not using one of the official client libraries. 18 | All of the messages are packed using link:https://www.npmjs.com/package/msgpack-lite[Msgpack lite]. 19 | 20 | === Packet specs 21 | ---- 22 | { 23 | t: 'event name', 24 | d: 'data associated with it' 25 | } 26 | ---- 27 | 28 | one underlying connection 29 | one underlying request 30 | 31 | => channels are routes 32 | => connection has it's own id 33 | => socket id is `name#connect-id` 34 | => multiple sockets one for each channels 35 | => socket disconnects using events 36 | => socket connects using events 37 | => packet reference https://github.com/socketio/socket.io-protocol#packet 38 | => 39 | -------------------------------------------------------------------------------- /doc/protocol.md: -------------------------------------------------------------------------------- 1 | # Adonis WebSocket 2 | Adonis WebSocket library works on top of [WebSocket protocol](https://tools.ietf.org/html/rfc6455) and uses [ws](https://github.com/websockets/ws) library as the base to build upon. 3 | 4 | This document describes the philosophy behind the library and shares the custom vocabulary added to the process. 5 | 6 | ## Terms Used 7 | #### Packet 8 | The packet sent from `client -> server` and `server -> client`. Each packet must have a type. 9 | 10 | #### Channels 11 | Channels makes it possible to separate the application concerns without creating a new TCP connection. 12 | 13 | #### Topics 14 | Topics are subscribed on a given channel. If channel name is static then the topic name will be the same as the channel name. 15 | 16 | For example: 17 | 18 | ```js 19 | Ws.channel('chat', function ({ socket }) { 20 | console.log(socket.topic) 21 | // will always be `chat` 22 | }) 23 | ``` 24 | 25 | If channel name has a wildcard, then multiple matching topics can be subscribed. 26 | 27 | ```js 28 | Ws.channel('chat:*', function ({ socket }) { 29 | }) 30 | ``` 31 | 32 | This time topic name can be anything after `chat:`. It can be `chat:watercooler` or `chat:design` and so on. 33 | 34 | The dynamic channel names makes it even simple to have a dynamic topics and a user can subscribe to any topic they want. 35 | 36 | ## Pure WebSockets 37 | Adonis WebSocket uses pure WebSocket connection and never relies on pooling. All of the browsers have support for WebSockets and there is no point in adding fallback layers. 38 | 39 | By creating a pure WebSocket connection, we make it easier to scale apps horizontally, without relying on sticky sessions. Whereas with solutions like `socket.io` you need sticky sessions and it's even harder to use Node.js cluster module. 40 | 41 | ## Multiplexing 42 | If you have worked with WebSockets earlier ( without any library ), you would have realized, there is no simple way to separate application concerns with in a single TCP connection. 43 | 44 | If you want to have multiple channels like `chat` and `news`, the client have to open 2 separate TCP connections, which is waste of resources and overhead on server and client both. 45 | 46 | Adonis introduces a layer of Channels, which uses a single TCP connection and uses messages a means of communicating within the channels. 47 | 48 | If you are a consumer of the Adonis WebSocket library, you will get a clean abstraction to make use of channels. 49 | 50 | If you are a developer creating a client library, then you will have to understand the concept of Packets and what they mean. 51 | 52 | ## Packets 53 | Packets are a way to communicate between client and server using WebSocket messages. 54 | 55 | For example: A packet to join a channel looks as follows. 56 | 57 | ```js 58 | { 59 | t: 1, 60 | d: { topic: 'chat' } 61 | } 62 | ``` 63 | 64 | 1. The `t` is the packet code. 65 | 2. And `d` is the packet data. Each packet type has it's own data requirements. 66 | 67 | Actions like `JOIN` and `LEAVE` are always acknowledged from the server with a successful acknowledgement or with an error. 68 | 69 | Following is an example of `JOIN_ERROR`. 70 | 71 | ```js 72 | { 73 | t: 4, 74 | d: { 75 | topic: 'chat', 76 | message: 'Topic has already been joined' 77 | } 78 | } 79 | ``` 80 | 81 | Here's the list of packet types and their codes. 82 | 83 | ```js 84 | { 85 | OPEN: 0, 86 | JOIN: 1, 87 | LEAVE: 2, 88 | JOIN_ACK: 3, 89 | JOIN_ERROR: 4, 90 | LEAVE_ACK: 5, 91 | LEAVE_ERROR: 6, 92 | EVENT: 7, 93 | PING: 8, 94 | PONG: 9 95 | } 96 | ``` 97 | 98 | **Why numbers?** : Because it's less data to transfer. 99 | 100 | A simple example of using Packets to recognize the type of message. The following code is supposed to be executed on browser. 101 | 102 | ```js 103 | // assuming Adonis encoders library is pulled from CDN. 104 | 105 | const ws = new WebSocket('ws://localhost:3333') 106 | const subscriptions = new Map() 107 | 108 | function makeJoinPacket (topic) { 109 | return { t: 1, d: { topic } } 110 | } 111 | 112 | ws.onopen = function () { 113 | // storing we initiated for the subscription 114 | subscriptions.set('chat', false) 115 | 116 | const payload = msgpack.encode(makeJoinPacket('chat')) 117 | ws.send(payload) 118 | } 119 | 120 | ws.onmessage = function (payload) { 121 | const packet = msgpack.decode(payload) 122 | 123 | if (packet.t && packet.t === 3) { 124 | // join acknowledgement from server 125 | } 126 | 127 | if (packet.t && packet.t === 4) { 128 | // join error from server 129 | } 130 | } 131 | ``` 132 | 133 | ## Contracts 134 | By now as you know the messaging packets are used to build the channels and topics flow, below is the list of contracts **client and server** has to follow. 135 | 136 | 1. **client**: `JOIN` packet must have a topic. 137 | 2. **server**: Server acknowledges the `JOIN` packet with `JOIN_ERROR` or `JOIN_ACK` packet. Both the packets will have the topic name in them. 138 | 3. **server**: Ensure a single TCP connection can join a topic only for one time. 139 | 4. **client**: Optionally can enforce a single topic subscription, since server will enforce it anyway. 140 | 5. **client**: `EVENT` packet must have a topic inside the message body, otherwise packet will be dropped by the server. 141 | 6. **server**: `EVENT` packet must have a topic inside the message body, otherwise packet will be dropped by the client too. 142 | 143 | The `LEAVE` flow works same as the `JOIN` flow. 144 | 145 | ## Ping/Pong 146 | Wish networks would have been reliable, but till then always be prepared for ungraceful disconnections. Ping/Pong is a standard way for client to know that a server is alive and vice-versa. 147 | 148 | In order to distribute load, AdonisJs never pings clients to find if they are alive or not, instead clients are expected to ping the server after given interval. 149 | 150 | If a client fails to ping the server, their connection will be dropped after defined number of retries. Also for every `ping`, client will receive a `pong` from the server, which tells the client that the server is alive. 151 | 152 | AdonisJs supports standard [ping/pong frames](https://tools.ietf.org/html/rfc6455#section-5.5.2) and if your client doesn't support sending these frames, then you can send a message with the `Packet type = PING`. 153 | 154 | 1. A single ping/pong game is played for a single TCP connection, there is no need to ping for each channel subscription. 155 | 2. When a connection is established, server sends an `OPEN` packet to the client, which contains the data to determine the ping interval. 156 | 157 | ```js 158 | { 159 | t: 0, 160 | d: { 161 | serverInterval: 30000, 162 | serverAttempts: 3, 163 | clientInterval: 25000, 164 | clientAttempts: 3, 165 | connId: 'connection unique id' 166 | } 167 | } 168 | ``` 169 | 170 | All of the times are in milliseconds and `clientAttempts` is the number of attempts to be made by the client before declaring server as dead and same is true for server using the `serverAttempts` property. 171 | 172 | ## Browser Support 173 | WebSockets are supported on all major browsers, so there is no point of adding weird fallbacks. 174 | https://caniuse.com/#feat=websockets 175 | -------------------------------------------------------------------------------- /instructions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | const path = require('path') 12 | 13 | async function copySocketFile (cli) { 14 | const inFile = path.join(__dirname, 'config', 'socket.js') 15 | const outFile = path.join(cli.helpers.appRoot(), 'start/socket.js') 16 | await cli.copy(inFile, outFile) 17 | cli.command.completed('create', 'start/socket.js') 18 | } 19 | 20 | async function copyKernelFile (cli) { 21 | const inFile = path.join(__dirname, 'config', 'wsKernel.js') 22 | const outFile = path.join(cli.helpers.appRoot(), 'start/wsKernel.js') 23 | await cli.copy(inFile, outFile) 24 | cli.command.completed('create', 'start/wsKernel.js') 25 | } 26 | 27 | async function copyConfigFile (cli) { 28 | const inFile = path.join(__dirname, 'config', 'index.js') 29 | const outFile = path.join(cli.helpers.configPath(), 'socket.js') 30 | await cli.copy(inFile, outFile) 31 | cli.command.completed('create', 'config/socket.js') 32 | } 33 | 34 | module.exports = async (cli) => { 35 | try { 36 | await copySocketFile(cli) 37 | await copyConfigFile(cli) 38 | await copyKernelFile(cli) 39 | } catch (error) { 40 | // ignore error 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /instructions.md: -------------------------------------------------------------------------------- 1 | # Register provider 2 | 3 | The provider must be registered inside `start/app.js` file. 4 | 5 | ```js 6 | const providers = [ 7 | '@adonisjs/websocket/providers/WsProvider' 8 | ] 9 | ``` 10 | 11 | ## Channels 12 | 13 | The next step is to open `start/socket.js` and register websocket channels. 14 | 15 | ```js 16 | const Ws = use('Ws') 17 | 18 | Ws.channel('chat', ({ socket }) => { 19 | console.log('new socket joined %s', socket.id) 20 | }) 21 | ``` 22 | 23 | ## Middleware 24 | 25 | The middleware for websocket are kept in the `start/wsKernel.js` file. 26 | 27 | 28 | ```js 29 | const Ws = use('Ws') 30 | 31 | const globalMiddleware = [] 32 | const namedMiddleware = {} 33 | 34 | Ws 35 | .registerGlobal(globalMiddleware) 36 | .registerNamed(namedMiddleware) 37 | ``` 38 | -------------------------------------------------------------------------------- /japaFile.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Enable it, since some tests rely on debug 5 | * statements 6 | * 7 | * @type {String} 8 | */ 9 | process.env.DEBUG = 'adonis:websocket' 10 | process.env.DEBUG_COLORS = false 11 | process.env.DEBUG_HIDE_DATE = true 12 | 13 | const cli = require('japa/cli') 14 | cli.run('test/**/*.spec.js') 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adonisjs/websocket", 3 | "version": "1.0.12", 4 | "description": "websocket server for Adonis framework", 5 | "main": "index.js", 6 | "files": [ 7 | "config", 8 | "providers", 9 | "src", 10 | "clusterPubSub.js", 11 | "instructions.js", 12 | "instructions.md" 13 | ], 14 | "scripts": { 15 | "mrm": "mrm --preset=@adonisjs/mrm-preset", 16 | "pretest": "npm run lint", 17 | "test": "nyc japa", 18 | "test:win": "node ./node_modules/japa-cli/index.js", 19 | "commit": "git-cz", 20 | "lint": "standard", 21 | "coverage": "nyc report --reporter=text-lcov | coveralls" 22 | }, 23 | "keywords": [ 24 | "adonisjs", 25 | "adonis", 26 | "websocket", 27 | "socket" 28 | ], 29 | "author": "virk", 30 | "license": "MIT", 31 | "devDependencies": { 32 | "@adonisjs/fold": "^4.0.9", 33 | "@adonisjs/framework": "^5.0.8", 34 | "@adonisjs/mrm-preset": "^1.0.9", 35 | "@adonisjs/sink": "^1.0.16", 36 | "commitizen": "^2.10.1", 37 | "coveralls": "^3.0.2", 38 | "cz-conventional-changelog": "^2.1.0", 39 | "japa": "^1.0.6", 40 | "japa-cli": "^1.0.1", 41 | "mrm": "^1.2.1", 42 | "nyc": "^12.0.2", 43 | "pkg-ok": "^2.2.0", 44 | "standard": "^11.0.1", 45 | "test-console": "^1.1.0" 46 | }, 47 | "dependencies": { 48 | "@adonisjs/generic-exceptions": "^2.0.1", 49 | "@adonisjs/middleware-base": "^1.0.0", 50 | "@adonisjs/websocket-packet": "^1.0.6", 51 | "cuid": "^2.1.1", 52 | "debug": "^3.1.0", 53 | "emittery": "^0.4.1", 54 | "macroable": "^1.0.0", 55 | "ws": "^5.2.2" 56 | }, 57 | "nyc": { 58 | "exclude": [ 59 | "test", 60 | "japaFile.js" 61 | ] 62 | }, 63 | "standard": { 64 | "ignore": [ 65 | "config" 66 | ] 67 | }, 68 | "config": { 69 | "commitizen": { 70 | "path": "cz-conventional-changelog" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /providers/WsProvider.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const { ServiceProvider } = require('@adonisjs/fold') 13 | 14 | class WsProvider extends ServiceProvider { 15 | /** 16 | * Register the Ws provider 17 | * 18 | * @method _registerWs 19 | * 20 | * @return {void} 21 | * 22 | * @private 23 | */ 24 | _registerWs () { 25 | this.app.singleton('Adonis/Addons/Ws', function (app) { 26 | const Ws = require('../src/Ws') 27 | return new Ws(app.use('Adonis/Src/Config')) 28 | }) 29 | this.app.alias('Adonis/Addons/Ws', 'Ws') 30 | } 31 | 32 | /** 33 | * Register the Ws context 34 | * 35 | * @method _registerWsContext 36 | * 37 | * @return {void} 38 | * 39 | * @private 40 | */ 41 | _registerWsContext () { 42 | this.app.bind('Adonis/Addons/WsContext', function (app) { 43 | return require('../src/Context') 44 | }) 45 | this.app.alias('Adonis/Addons/WsContext', 'WsContext') 46 | } 47 | 48 | /** 49 | * Register all required providers 50 | * 51 | * @method register 52 | * 53 | * @return {void} 54 | */ 55 | register () { 56 | this._registerWs() 57 | this._registerWsContext() 58 | } 59 | 60 | /** 61 | * Add request getter to the WsContext 62 | * 63 | * @method boot 64 | * 65 | * @return {void} 66 | */ 67 | boot () { 68 | const WsContext = this.app.use('Adonis/Addons/WsContext') 69 | const Request = this.app.use('Adonis/Src/Request') 70 | const Config = this.app.use('Adonis/Src/Config') 71 | 72 | WsContext.getter('request', function () { 73 | const request = new Request(this.req, {}, Config) 74 | request.websocket = true 75 | return request 76 | }, true) 77 | } 78 | } 79 | 80 | module.exports = WsProvider 81 | -------------------------------------------------------------------------------- /src/Channel/Manager.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const Channel = require('./index') 13 | 14 | /** 15 | * Manages the list of registered channels. Also this class is used to return 16 | * the matching channel for a given topic. 17 | * 18 | * @class ChannelsManager 19 | */ 20 | class ChannelsManager { 21 | constructor () { 22 | this.channels = new Map() 23 | this._channelExpressions = [] 24 | } 25 | 26 | /** 27 | * Normalizes the channel name by removing starting and 28 | * ending slashes 29 | * 30 | * @method _normalizeName 31 | * 32 | * @param {String} name 33 | * 34 | * @return {String} 35 | * 36 | * @private 37 | */ 38 | _normalizeName (name) { 39 | return name.replace(/^\/|\/$/, '') 40 | } 41 | 42 | /** 43 | * Generates regex expression for the channel name, it is 44 | * used to match topics and find the right channel for it. 45 | * 46 | * @method _generateExpression 47 | * 48 | * @param {String} name 49 | * 50 | * @return {RegExp} 51 | * 52 | * @private 53 | */ 54 | _generateExpression (name) { 55 | return name.endsWith('*') ? new RegExp(`^${name.replace(/\*$/, '\\w+')}`) : new RegExp(`^${name}$`) 56 | } 57 | 58 | /** 59 | * Resets channels array 60 | * 61 | * @method clear 62 | * 63 | * @return {void} 64 | */ 65 | clear () { 66 | this.channels = new Map() 67 | this._channelExpressions = [] 68 | } 69 | 70 | /** 71 | * Adds a new channel to the store 72 | * 73 | * @method add 74 | * 75 | * @param {String} name 76 | * @param {Function} onConnect 77 | */ 78 | add (name, onConnect) { 79 | name = this._normalizeName(name) 80 | 81 | /** 82 | * Instantiating a new channel 83 | * 84 | * @type {Channel} 85 | */ 86 | const channel = new Channel(name, onConnect) 87 | 88 | /** 89 | * Generate expressions for matching topics 90 | * and resolving channel. 91 | */ 92 | this.channels.set(name, channel) 93 | this._channelExpressions.push({ expression: this._generateExpression(name), name }) 94 | 95 | return channel 96 | } 97 | 98 | /** 99 | * Returns an existing channel instance 100 | * 101 | * @method get 102 | * 103 | * @param {String} name 104 | * 105 | * @return {Channel} 106 | */ 107 | get (name) { 108 | return this.channels.get(name) 109 | } 110 | 111 | /** 112 | * Returns channel for a given topic 113 | * 114 | * @method resolve 115 | * 116 | * @param {String} topic 117 | * 118 | * @return {Channel|Null} 119 | */ 120 | resolve (topic) { 121 | const matchedExpression = this._channelExpressions.find((expression) => expression.expression.test(topic)) 122 | return matchedExpression ? this.channels.get(matchedExpression.name) : null 123 | } 124 | } 125 | 126 | module.exports = new ChannelsManager() 127 | -------------------------------------------------------------------------------- /src/Channel/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const GE = require('@adonisjs/generic-exceptions') 13 | const debug = require('debug')('adonis:websocket') 14 | const middleware = require('../Middleware') 15 | 16 | /** 17 | * Channel class gives a simple way to divide the application 18 | * level concerns by maintaing a single TCP connection. 19 | * 20 | * @class Channel 21 | * 22 | * @param {String} name Unique channel name 23 | * @param {Function} onConnect Function to be invoked when a socket joins a Channel 24 | */ 25 | class Channel { 26 | constructor (name, onConnect) { 27 | this._validateArguments(name, onConnect) 28 | this.name = name 29 | 30 | this._onConnect = onConnect 31 | 32 | /** 33 | * If channel controller is an ES6 class, then we let users 34 | * define listeners using a convention by prefixing `on` 35 | * in front of their methods. 36 | * 37 | * Instead of re-findings these listeners again and again on 38 | * the class prototype, we just pull them for once. 39 | * 40 | * @type {Array} 41 | */ 42 | this._channelControllerListeners = [] 43 | 44 | /** 45 | * All of the channel subscriptions are grouped 46 | * together as per their topics. 47 | * 48 | * @example 49 | * this.subscriptions.set('chat:watercooler', new Set()) 50 | * this.subscriptions.set('chat:general', new Set()) 51 | * 52 | * @type {Map} 53 | */ 54 | this.subscriptions = new Map() 55 | 56 | /** 57 | * Named middleware defined on the channel 58 | */ 59 | this._middleware = [] 60 | 61 | /** 62 | * The method attached as an event listener to each 63 | * subscription. 64 | */ 65 | this.deleteSubscription = function (subscription) { 66 | const topic = this.subscriptions.get(subscription.topic) 67 | if (topic) { 68 | debug('removing channel subscription for %s topic', subscription.topic) 69 | topic.delete(subscription) 70 | } 71 | }.bind(this) 72 | } 73 | 74 | /** 75 | * Validate the new instance arguments to make sure we 76 | * can instantiate the channel. 77 | * 78 | * @method _validateArguments 79 | * 80 | * @param {String} name 81 | * @param {Function} onConnect 82 | * 83 | * @return {void} 84 | * 85 | * @throws {InvalidArgumentException} If arguments are incorrect 86 | * 87 | * @private 88 | */ 89 | _validateArguments (name, onConnect) { 90 | if (typeof (name) !== 'string' || !name) { 91 | throw GE.InvalidArgumentException.invalidParameter('Expected channel name to be string') 92 | } 93 | 94 | if (typeof (onConnect) !== 'function' && typeof (onConnect) !== 'string') { 95 | throw GE.InvalidArgumentException.invalidParameter('Expected channel callback to be a function') 96 | } 97 | } 98 | 99 | /** 100 | * Executes the middleware stack 101 | * 102 | * @method _executeMiddleware 103 | * 104 | * @param {Object} context 105 | * 106 | * @return {Promise} 107 | * 108 | * @private 109 | */ 110 | _executeMiddleware (context) { 111 | return middleware 112 | .composeGlobalAndNamed(this._middleware) 113 | .params([context]) 114 | .run() 115 | } 116 | 117 | /** 118 | * Returns the channel controller Class when it is a string. 119 | * 120 | * This method relies of the globals of `ioc container`. 121 | * 122 | * @method _getChannelController 123 | * 124 | * @return {Class} 125 | * 126 | * @private 127 | */ 128 | _getChannelController () { 129 | const namespace = global.iocResolver.forDir('wsControllers').translate(this._onConnect) 130 | return global.use(namespace) 131 | } 132 | 133 | /** 134 | * Returns the listeners on the controller class 135 | * 136 | * @method _getChannelControllerListeners 137 | * 138 | * @param {Class} Controller 139 | * 140 | * @return {Array} 141 | * 142 | * @private 143 | */ 144 | _getChannelControllerListeners (Controller) { 145 | if (!this._channelControllerListeners.length) { 146 | /** 147 | * Looping over each method of the class prototype 148 | * and pulling listeners from them 149 | */ 150 | this._channelControllerListeners = Object 151 | .getOwnPropertyNames(Controller.prototype) 152 | .filter((method) => method.startsWith('on') && method !== 'on') 153 | .map((method) => { 154 | const eventName = method.replace(/^on(\w)/, (match, group) => group.toLowerCase()) 155 | return { eventName, method } 156 | }) 157 | } 158 | 159 | return this._channelControllerListeners 160 | } 161 | 162 | /** 163 | * Invokes the onConnect handler for the channel. 164 | * 165 | * @method _callOnConnect 166 | * 167 | * @param {Object} context 168 | * 169 | * @return {void} 170 | */ 171 | _callOnConnect (context) { 172 | /** 173 | * When the onConnect handler is a plain function 174 | */ 175 | if (typeof (this._onConnect) === 'function') { 176 | process.nextTick(() => { 177 | this._onConnect(context) 178 | }) 179 | return 180 | } 181 | 182 | /** 183 | * When onConnect handler is a reference to the channel 184 | * controler 185 | */ 186 | const Controller = this._getChannelController() 187 | const controllerListeners = this._getChannelControllerListeners(Controller) 188 | 189 | /** 190 | * Calling onConnect in the next tick, so that the parent 191 | * connection saves a reference to it, before the closure 192 | * is executed. 193 | */ 194 | process.nextTick(() => { 195 | const controller = new Controller(context) 196 | controllerListeners.forEach((item) => { 197 | context.socket.on(item.eventName, controller[item.method].bind(controller)) 198 | }) 199 | }) 200 | } 201 | 202 | /** 203 | * Returns the subscriptions set for a given topic. If there are no 204 | * subscriptions, an empty set will be initialized and returned. 205 | * 206 | * @method getTopicSubscriptions 207 | * 208 | * @param {String} name 209 | * 210 | * @return {Set} 211 | */ 212 | getTopicSubscriptions (topic) { 213 | if (!this.subscriptions.has(topic)) { 214 | this.subscriptions.set(topic, new Set()) 215 | } 216 | return this.subscriptions.get(topic) 217 | } 218 | 219 | /** 220 | * Returns the first subscription from all the existing subscriptions 221 | * 222 | * @method getFirstSubscription 223 | * 224 | * @param {String} topic 225 | * 226 | * @return {Socket} 227 | */ 228 | getFirstSubscription (topic) { 229 | const subscriptions = this.getTopicSubscriptions(topic) 230 | return subscriptions.values().next().value 231 | } 232 | 233 | /** 234 | * Join a topic by saving the subscription reference. This method 235 | * will execute the middleware chain before saving the 236 | * subscription reference and invoking the onConnect 237 | * callback. 238 | * 239 | * @method joinTopic 240 | * 241 | * @param {Context} context 242 | * 243 | * @return {void} 244 | */ 245 | async joinTopic (context) { 246 | await this._executeMiddleware(context) 247 | const subscriptions = this.getTopicSubscriptions(context.socket.topic) 248 | 249 | /** 250 | * Add new subscription to existing subscriptions 251 | */ 252 | subscriptions.add(context.socket) 253 | debug('adding channel subscription for %s topic', context.socket.topic) 254 | 255 | /** 256 | * Add reference of channel to the subscription 257 | */ 258 | context.socket.associateChannel(this) 259 | 260 | /** 261 | * Binding to close event, so that we can clear the 262 | * subscription object from the subscriptions 263 | * set. 264 | */ 265 | context.socket.on('close', this.deleteSubscription) 266 | 267 | this._callOnConnect(context) 268 | } 269 | 270 | /** 271 | * Add middleware to the channel. It will be called everytime a 272 | * subscription joins a topic 273 | * 274 | * @method middleware 275 | * 276 | * @param {Function|Function[]} middleware 277 | * 278 | * @chainable 279 | */ 280 | middleware (middleware) { 281 | const middlewareList = Array.isArray(middleware) ? middleware : [middleware] 282 | this._middleware = this._middleware.concat(middlewareList) 283 | return this 284 | } 285 | 286 | /** 287 | * Scope broadcasting to a given topic 288 | * 289 | * @method topic 290 | * 291 | * @param {String} topic 292 | * 293 | * @return {Object|Null} 294 | */ 295 | topic (topic) { 296 | const socket = this.getFirstSubscription(topic) 297 | if (!socket) { 298 | return null 299 | } 300 | 301 | return { 302 | socket, 303 | /** 304 | * Broadcast to everyone 305 | * 306 | * @method broadcast 307 | * @alias broadcastToAll 308 | * 309 | * @param {String} event 310 | * @param {Mixed} data 311 | * 312 | * @return {void} 313 | */ 314 | broadcast (event, data) { 315 | this.socket.broadcastToAll(event, data) 316 | }, 317 | 318 | /** 319 | * Broadcast to everyone 320 | * 321 | * @method broadcastToAll 322 | * 323 | * @param {String} event 324 | * @param {Mixed} data 325 | * 326 | * @return {void} 327 | */ 328 | broadcastToAll (event, data) { 329 | this.socket.broadcastToAll(event, data) 330 | }, 331 | 332 | /** 333 | * emit to certain ids 334 | * 335 | * @method emitTo 336 | * 337 | * @param {String} event 338 | * @param {Mixed} data 339 | * @param {Array} ids 340 | * 341 | * @return {void} 342 | */ 343 | emitTo (event, data, ids) { 344 | this.socket.emitTo(event, data, ids) 345 | } 346 | } 347 | } 348 | 349 | /** 350 | * Broadcast event message to a given topic. 351 | * 352 | * @method broadcastPayload 353 | * 354 | * @param {String} topic 355 | * @param {String} payload 356 | * @param {Array} filterSockets 357 | * @param {Boolean} inverse 358 | * 359 | * @return {void} 360 | */ 361 | broadcastPayload (topic, payload, filterSockets = [], inverse) { 362 | this.getTopicSubscriptions(topic).forEach((socket) => { 363 | const socketIndex = filterSockets.indexOf(socket.id) 364 | const shouldSend = inverse ? socketIndex > -1 : socketIndex === -1 365 | 366 | if (shouldSend) { 367 | socket.connection.write(payload) 368 | } 369 | }) 370 | } 371 | 372 | /** 373 | * Invoked when a message is received on cluster node 374 | * 375 | * @method clusterBroadcast 376 | * 377 | * @param {String} topic 378 | * @param {String} payload 379 | * 380 | * @return {void} 381 | */ 382 | clusterBroadcast (topic, payload) { 383 | this.broadcast(topic, payload, []) 384 | } 385 | } 386 | 387 | module.exports = Channel 388 | -------------------------------------------------------------------------------- /src/ClusterHop/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const cluster = require('cluster') 13 | const debug = require('debug')('adonis:websocket') 14 | const receiver = require('./receiver') 15 | const sender = require('./sender') 16 | 17 | module.exports = { 18 | /** 19 | * Bind listener to listen for process message 20 | * 21 | * @method init 22 | * 23 | * @return {void} 24 | */ 25 | init () { 26 | if (cluster.isWorker) { 27 | debug('adding listener from worker to receive node message') 28 | process.on('message', receiver) 29 | } 30 | }, 31 | 32 | /** 33 | * Sends a message out from the process. The cluster should bind 34 | * listener for listening messages. 35 | * 36 | * @method send 37 | * 38 | * @param {String} handle 39 | * @param {String} topic 40 | * @param {Object} payload 41 | * 42 | * @return {void} 43 | */ 44 | send (handle, topic, payload) { 45 | if (cluster.isWorker) { 46 | sender(handle, topic, payload) 47 | } 48 | }, 49 | 50 | /** 51 | * Clear up event listeners 52 | * 53 | * @method destroy 54 | * 55 | * @return {void} 56 | */ 57 | destroy () { 58 | debug('cleaning up cluster listeners') 59 | process.removeListener('message', receiver) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ClusterHop/receiver.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const ChannelsManager = require('../Channel/Manager') 13 | const debug = require('debug')('adonis:websocket') 14 | 15 | /** 16 | * Delivers the message from process to the channel 17 | * 18 | * @method deliverMessage 19 | * 20 | * @param {String} handle 21 | * @param {String} topic 22 | * @param {String} payload 23 | * 24 | * @return {void} 25 | */ 26 | function deliverMessage (handle, topic, payload) { 27 | if (handle === 'broadcast') { 28 | const channel = ChannelsManager.resolve(topic) 29 | 30 | if (!channel) { 31 | return debug('broadcast topic %s cannot be handled by any channel', topic) 32 | } 33 | 34 | channel.clusterBroadcast(topic, payload) 35 | return 36 | } 37 | 38 | debug('dropping packet, since %s handle is not allowed', handle) 39 | } 40 | 41 | /** 42 | * Handles the messages received on a given process 43 | * 44 | * @method handleProcessMessage 45 | * 46 | * @param {String} message 47 | * 48 | * @return {void} 49 | */ 50 | module.exports = function handleProcessMessage (message) { 51 | let decoded = null 52 | 53 | /** 54 | * Decoding the JSON message 55 | */ 56 | try { 57 | decoded = JSON.parse(message) 58 | } catch (error) { 59 | debug('dropping packet, since not valid json') 60 | return 61 | } 62 | 63 | /** 64 | * Ignoring packet when there is no handle 65 | */ 66 | if (!decoded.handle) { 67 | debug('dropping packet, since handle is missing') 68 | return 69 | } 70 | 71 | /** 72 | * Safely trying to deliver cluster messages 73 | */ 74 | try { 75 | deliverMessage(decoded.handle, decoded.topic, decoded.payload) 76 | } catch (error) { 77 | debug('unable to process cluster message with error %o', error) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/ClusterHop/sender.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | const debug = require('debug')('adonis:websocket') 12 | 13 | module.exports = function (handle, topic, payload) { 14 | try { 15 | process.send(JSON.stringify({ handle, topic, payload })) 16 | } catch (error) { 17 | debug('cluster.send error %o', error) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Connection/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const cuid = require('cuid') 13 | const Emittery = require('emittery') 14 | const debug = require('debug')('adonis:websocket') 15 | const msp = require('@adonisjs/websocket-packet') 16 | 17 | const Context = require('../Context') 18 | const Socket = require('../Socket') 19 | const ChannelsManager = require('../Channel/Manager') 20 | 21 | /** 22 | * Connection is an instance of a single TCP connection. This class decodes the 23 | * packets and use them to run operations like. `JOIN/LEAVE A TOPIC`, `EMIT EVENT` 24 | * and so on. 25 | * 26 | * 1. Each connection is given a unique id 27 | * 2. The connection will also maintain a list of subscriptions for a connection 28 | * 29 | * @class Connection 30 | * 31 | * @param {Object} ws The underlying socket connection 32 | * @param {Object} req Request object 33 | * @param {Object} encoder Encoder to be used for encoding/decoding messages 34 | */ 35 | class Connection extends Emittery { 36 | constructor (ws, req, encoder) { 37 | super() 38 | 39 | this.ws = ws 40 | this.req = req 41 | 42 | /** 43 | * Each connection must have a unique id. The `cuid` keeps 44 | * it unique across the cluster 45 | * 46 | * @type {String} 47 | */ 48 | this.id = cuid() 49 | 50 | /** 51 | * The encoder is used to encode and decode packets. Note this is 52 | * not encryption or decryption, encoders are used to translate 53 | * data-types into raw string. 54 | * 55 | * @type {Object} 56 | */ 57 | this._encoder = encoder 58 | 59 | /** 60 | * A connection can have multiple subscriptions for a given channel. 61 | * 62 | * @type {Map} 63 | */ 64 | this._subscriptions = new Map() 65 | 66 | /** 67 | * An array of subscriptions queue, we let connection join 68 | * topics one by one and not in parallel. 69 | * 70 | * This also helps in avoiding duplicate subscriptions 71 | * to the same topic. 72 | * 73 | * @type {Array} 74 | */ 75 | this._subscriptionsQueue = [] 76 | 77 | /** 78 | * A flag to tell whether the queue is in process or not. 79 | * 80 | * @type {Boolean} 81 | */ 82 | this._processingQueue = false 83 | 84 | /** 85 | * Added as a listener to `onclose` event of the subscription. 86 | */ 87 | this.deleteSubscription = function ({ topic }) { 88 | debug('removed subscription for %s topic', topic) 89 | this._subscriptions.delete(topic) 90 | }.bind(this) 91 | 92 | /** 93 | * The number of times ping check has been done. This 94 | * counter will reset, anytime client will ping 95 | * or send any sort of frames. 96 | * 97 | * @type {Number} 98 | */ 99 | this.pingElapsed = 0 100 | 101 | /** 102 | * Event listeners 103 | */ 104 | this.ws.on('message', this._onMessage.bind(this)) 105 | this.ws.on('error', this._onError.bind(this)) 106 | this.ws.on('close', this._onClose.bind(this)) 107 | } 108 | 109 | /** 110 | * Returns the ready state of the underlying 111 | * ws connection 112 | * 113 | * @method readyState 114 | * 115 | * @return {Number} 116 | */ 117 | get readyState () { 118 | return this.ws.readyState 119 | } 120 | 121 | /** 122 | * Notifies about the drop packets. This method will log 123 | * them to the debug output 124 | * 125 | * @method _notifyPacketDropped 126 | * 127 | * @param {Function} fn 128 | * @param {String} reason 129 | * 130 | * @return {void} 131 | * 132 | * @private 133 | */ 134 | _notifyPacketDropped (fn, message, ...args) { 135 | debug(`${fn}:${message}`, ...args) 136 | } 137 | 138 | /** 139 | * Opens the packet by decoding it. 140 | * 141 | * @method _openPacket 142 | * 143 | * @param {Buffer} packet 144 | * 145 | * @return {Promise} 146 | * 147 | * @private 148 | */ 149 | _openPacket (packet) { 150 | return new Promise((resolve) => { 151 | this._encoder.decode(packet, (error, payload) => { 152 | if (error) { 153 | return resolve({}) 154 | } 155 | resolve(payload) 156 | }) 157 | }) 158 | } 159 | 160 | /** 161 | * Invoked everytime a new message is received. This method will 162 | * open the packet and handles it based upon the packet type. 163 | * 164 | * Invalid packets are dropped. 165 | * 166 | * @method _onMessage 167 | * 168 | * @param {Object} packet 169 | * 170 | * @return {void} 171 | * 172 | * @private 173 | */ 174 | _onMessage (packet) { 175 | /** 176 | * Reset ping elapsed 177 | * 178 | * @type {Number} 179 | */ 180 | this.pingElapsed = 0 181 | 182 | this 183 | ._openPacket(packet) 184 | .then((payload) => { 185 | if (!payload.t) { 186 | this._notifyPacketDropped('_onMessage', 'packet dropped, there is no {t} property %j', payload) 187 | return 188 | } 189 | this._handleMessage(payload) 190 | }) 191 | } 192 | 193 | /** 194 | * Handles the message packet, this method is invoked when 195 | * packet is valid and must be handled. 196 | * 197 | * @method _handleMessage 198 | * 199 | * @param {Object} packet 200 | * 201 | * @return {void} 202 | * 203 | * @private 204 | */ 205 | _handleMessage (packet) { 206 | /** 207 | * Subscription related packet 208 | */ 209 | if (msp.isJoinPacket(packet) || msp.isLeavePacket(packet)) { 210 | this._subscriptionsQueue.push(packet) 211 | this._advanceQueue() 212 | return 213 | } 214 | 215 | /** 216 | * Event packet 217 | */ 218 | if (msp.isEventPacket(packet)) { 219 | this._processEvent(packet) 220 | return 221 | } 222 | 223 | /** 224 | * Ping from client 225 | */ 226 | if (msp.isPingPacket(packet)) { 227 | this.sendPacket(msp.pongPacket()) 228 | return 229 | } 230 | 231 | this._notifyPacketDropped('_handleMessage', 'invalid packet %j', packet) 232 | } 233 | 234 | /** 235 | * Processes the event by ensuring the packet is valid and there 236 | * is a subscription for the given topic. 237 | * 238 | * @method _processEvent 239 | * 240 | * @param {Object} packet 241 | * 242 | * @return {void} 243 | */ 244 | _processEvent (packet) { 245 | if (!msp.isValidEventPacket(packet)) { 246 | this._notifyPacketDropped('_processEvent', 'dropping event since packet is invalid %j', packet) 247 | return 248 | } 249 | 250 | if (!this.hasSubscription(packet.d.topic)) { 251 | this._notifyPacketDropped('_processEvent', 'dropping event since there are no subscription %j', packet) 252 | return 253 | } 254 | 255 | this.getSubscription(packet.d.topic).serverMessage(packet.d) 256 | } 257 | 258 | /** 259 | * Process the subscription packets, one at a time in 260 | * sequence. 261 | * 262 | * @method _getSubscriptionHandle 263 | * 264 | * @param {Object} packet 265 | * 266 | * @return {void} 267 | * 268 | * @private 269 | */ 270 | _getSubscriptionHandle (packet) { 271 | return msp.isJoinPacket(packet) ? this._joinTopic(packet) : this._leaveTopic(packet) 272 | } 273 | 274 | /** 275 | * Advances the join queue until there are join 276 | * packets in the queue 277 | * 278 | * @method _advanceQueue 279 | * 280 | * @return {void} 281 | * 282 | * @private 283 | */ 284 | _advanceQueue () { 285 | /** 286 | * Exit early when processing queue 287 | */ 288 | if (this._processingQueue) { 289 | return 290 | } 291 | 292 | /** 293 | * Pick next packet from the queue 294 | */ 295 | const nextPacket = this._subscriptionsQueue.shift() 296 | if (!nextPacket) { 297 | return 298 | } 299 | 300 | /** 301 | * Set processing flag to avoid parallel processing 302 | * 303 | * @type {Boolean} 304 | */ 305 | this._processingQueue = true 306 | 307 | this 308 | ._getSubscriptionHandle(nextPacket) 309 | .then((responsePacket) => { 310 | this.sendPacket(responsePacket) 311 | this._processingQueue = false 312 | this._advanceQueue() 313 | }) 314 | .catch((errorPacket) => { 315 | this.sendPacket(errorPacket) 316 | this._processingQueue = false 317 | this._advanceQueue() 318 | }) 319 | } 320 | 321 | /** 322 | * Joins the topic. The consumer of this function should make sure 323 | * that the packet type is correct when sending to this function. 324 | * 325 | * @method _joinTopic 326 | * 327 | * @param {Object} packet 328 | * 329 | * @return {void} 330 | * 331 | * @private 332 | */ 333 | _joinTopic (packet) { 334 | return new Promise((resolve, reject) => { 335 | /** 336 | * The join packet is invalid, since the topic name is missing 337 | */ 338 | if (!msp.isValidJoinPacket(packet)) { 339 | return reject(msp.joinErrorPacket('unknown', 'Missing topic name')) 340 | } 341 | 342 | /** 343 | * Make sure the same connection is not subscribed to this 344 | * topic already. 345 | */ 346 | if (this.hasSubscription(packet.d.topic)) { 347 | return reject(msp.joinErrorPacket(packet.d.topic, 'Cannot join the same topic twice')) 348 | } 349 | 350 | /** 351 | * Ensure topic channel does exists, otherwise return error 352 | */ 353 | const channel = ChannelsManager.resolve(packet.d.topic) 354 | if (!channel) { 355 | return reject(msp.joinErrorPacket(packet.d.topic, 'Topic cannot be handled by any channel')) 356 | } 357 | 358 | /** 359 | * Grap current subscription context 360 | * 361 | * @type {Context} 362 | */ 363 | const context = new Context(this.req) 364 | context.socket = new Socket(packet.d.topic, this) 365 | 366 | channel 367 | .joinTopic(context) 368 | .then(() => { 369 | this.addSubscription(packet.d.topic, context.socket) 370 | resolve(msp.joinAckPacket(packet.d.topic)) 371 | }) 372 | .catch((error) => { 373 | reject(msp.joinErrorPacket(packet.d.topic, error.message)) 374 | }) 375 | }) 376 | } 377 | 378 | /** 379 | * Leaves the topic by removing subscriptions 380 | * 381 | * @method _leaveTopic 382 | * 383 | * @param {Object} packet 384 | * 385 | * @return {void} 386 | * 387 | * @private 388 | */ 389 | _leaveTopic (packet) { 390 | return new Promise((resolve, reject) => { 391 | /** 392 | * The leave packet is invalid, since the topic name is missing 393 | */ 394 | if (!msp.isValidLeavePacket(packet)) { 395 | return reject(msp.leaveErrorPacket('unknown', 'Missing topic name')) 396 | } 397 | 398 | /** 399 | * Close subscription 400 | */ 401 | this.closeSubscription(this.getSubscription(packet.d.topic)) 402 | 403 | /** 404 | * Finally ACK the leave 405 | */ 406 | resolve(msp.leaveAckPacket(packet.d.topic)) 407 | }) 408 | } 409 | 410 | /** 411 | * Invoked when connection receives an error 412 | * 413 | * @method _onError 414 | * 415 | * @return {void} 416 | * 417 | * @private 418 | */ 419 | _onError (code, reason) { 420 | debug('connection error code:%s, reason:%s', code, reason) 421 | this._subscriptions.forEach((socket) => { 422 | socket.serverError(code, reason) 423 | }) 424 | } 425 | 426 | /** 427 | * Invoked when TCP connection is closed. We will have to close 428 | * all the underlying subscriptions too. 429 | * 430 | * @method _onClose 431 | * 432 | * @return {void} 433 | * 434 | * @private 435 | */ 436 | _onClose () { 437 | this._subscriptions.forEach((subscription) => (this.closeSubscription(subscription))) 438 | debug('closing underlying connection') 439 | 440 | this 441 | .emit('close', this) 442 | .then(() => (this.clearListeners())) 443 | .catch(() => (this.clearListeners())) 444 | } 445 | 446 | /** 447 | * Add a new subscription socket for a given topic 448 | * 449 | * @method addSubscription 450 | * 451 | * @param {String} topic 452 | * @param {Socket} subscription 453 | * 454 | * @returns {void} 455 | */ 456 | addSubscription (topic, subscription) { 457 | debug('new subscription for %s topic', topic) 458 | 459 | subscription.on('close', this.deleteSubscription) 460 | this._subscriptions.set(topic, subscription) 461 | } 462 | 463 | /** 464 | * Returns a boolean whether there is a socket 465 | * for a given topic or not. 466 | * 467 | * @method hasSubscription 468 | * 469 | * @param {String} topic 470 | * 471 | * @return {Boolean} 472 | */ 473 | hasSubscription (topic) { 474 | return this._subscriptions.has(topic) 475 | } 476 | 477 | /** 478 | * Returns the socket instance for a given topic 479 | * for a given channel 480 | * 481 | * @method getSubscription 482 | * 483 | * @param {Object} topic 484 | * 485 | * @return {Socket} 486 | */ 487 | getSubscription (topic) { 488 | return this._subscriptions.get(topic) 489 | } 490 | 491 | /** 492 | * Closes the subscription for a given topic on connection 493 | * 494 | * @method closeSubscription 495 | * 496 | * @param {Object} subscription 497 | * 498 | * @return {void} 499 | */ 500 | closeSubscription (subscription) { 501 | if (subscription) { 502 | subscription.serverClose() 503 | } 504 | } 505 | 506 | /** 507 | * Encodes the packet to be sent over the wire 508 | * 509 | * @method encodePacket 510 | * 511 | * @param {Object} packet 512 | * @param {Function} cb 513 | * 514 | * @return {void} 515 | */ 516 | encodePacket (packet, cb) { 517 | this._encoder.encode(packet, (error, payload) => { 518 | if (error) { 519 | debug('encode: error %j', error) 520 | cb(error) 521 | return 522 | } 523 | 524 | cb(null, payload) 525 | }) 526 | } 527 | 528 | /** 529 | * Sends the packet to the underlying connection by encoding 530 | * it. 531 | * 532 | * If socket connection is closed, the packet will be dropped 533 | * 534 | * @method sendPacket 535 | * 536 | * @param {Object} packet 537 | * @param {Object} [options] 538 | * @param {Function} ack 539 | * 540 | * @return {void} 541 | */ 542 | sendPacket (packet, options = {}, ack) { 543 | ack = typeof (ack) === 'function' ? ack : function () {} 544 | this.encodePacket(packet, (error, payload) => { 545 | if (error) { 546 | debug('encode: error %j', error) 547 | ack(error) 548 | return 549 | } 550 | 551 | this.write(payload, options, ack) 552 | }) 553 | } 554 | 555 | /** 556 | * Writes to the underlying socket. Also this method 557 | * makes sure that the connection is open 558 | * 559 | * @method write 560 | * 561 | * @param {String} payload 562 | * @param {Object} options 563 | * @param {Function} [ack] 564 | * 565 | * @return {void} 566 | */ 567 | write (payload, options, ack) { 568 | ack = typeof (ack) === 'function' ? ack : function () {} 569 | 570 | if (this.readyState !== this.ws.constructor.OPEN) { 571 | this._notifyPacketDropped('sendPacket', 'packet dropped since connection is closed') 572 | ack(new Error('connection is closed')) 573 | return 574 | } 575 | 576 | this.ws.send(payload, options, ack) 577 | } 578 | 579 | /** 580 | * Sends the open packet on the connection as soon as 581 | * the connection has been made 582 | * 583 | * @method sendOpenPacket 584 | * 585 | * @package {Object} options 586 | * 587 | * @return {void} 588 | */ 589 | sendOpenPacket (options) { 590 | this.sendPacket({ t: msp.codes.OPEN, d: options }) 591 | } 592 | 593 | /** 594 | * Sends the leave packet, when the subscription to a channel 595 | * has been closed from the server. 596 | * 597 | * @method sendLeavePacket 598 | * 599 | * @param {String} topic 600 | * 601 | * @return {void} 602 | */ 603 | sendLeavePacket (topic) { 604 | debug('initiating leave from server for %s topic', topic) 605 | this.sendPacket(msp.leavePacket(topic)) 606 | } 607 | 608 | /** 609 | * Makes the event packet from the topic and the 610 | * body 611 | * 612 | * @method makeEventPacket 613 | * 614 | * @param {String} topic 615 | * @param {String} event 616 | * @param {Mixed} data 617 | * 618 | * @return {Object} 619 | */ 620 | makeEventPacket (topic, event, data) { 621 | if (!topic) { 622 | throw new Error('Cannot send event without a topic') 623 | } 624 | 625 | if (!this.hasSubscription(topic)) { 626 | throw new Error(`Topic ${topic} doesn't have any active subscriptions`) 627 | } 628 | 629 | return msp.eventPacket(topic, event, data) 630 | } 631 | 632 | /** 633 | * Sends the event to the underlying connection 634 | * 635 | * @method sendEvent 636 | * 637 | * @param {String} topic 638 | * @param {String} event 639 | * @param {Mixed} data 640 | * @param {Function} [ack] 641 | * 642 | * @return {void} 643 | */ 644 | sendEvent (topic, event, data, ack) { 645 | this.sendPacket(this.makeEventPacket(topic, event, data), {}, ack) 646 | } 647 | 648 | /** 649 | * Close the underlying ws connection. This method will 650 | * initiate a closing handshake. 651 | * 652 | * @method close 653 | * 654 | * @param {Number} code 655 | * @param {String} [reason] 656 | * 657 | * @return {void} 658 | */ 659 | close (code, reason) { 660 | debug('closing connection from server') 661 | this.ws.close(code, reason) 662 | } 663 | 664 | /** 665 | * Terminates the connection forcefully. This is called when client 666 | * doesn't ping the server. 667 | * 668 | * @method terminate 669 | * 670 | * @package {String} reason 671 | * 672 | * @return {void} 673 | */ 674 | terminate (reason) { 675 | debug('terminating connection: %s', reason) 676 | this.ws.terminate() 677 | } 678 | } 679 | 680 | module.exports = Connection 681 | -------------------------------------------------------------------------------- /src/Context/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const Macroable = require('macroable') 13 | 14 | /** 15 | * An instance of this class is passed to all websocket 16 | * handlers and middleware. 17 | * 18 | * @binding Adonis/Src/WsContext 19 | * @alias WsContext 20 | * @group Ws 21 | * 22 | * @class WsContext 23 | * @constructor 24 | * 25 | * @example 26 | * ```js 27 | * const WsContext = use('WsContext') 28 | * 29 | * WsContext.getter('view', function () { 30 | * return new View() 31 | * }, true) 32 | * 33 | * // The last option `true` means the getter is singleton. 34 | * ``` 35 | */ 36 | class WsContext extends Macroable { 37 | constructor (req) { 38 | super() 39 | 40 | /** 41 | * Websocket req object 42 | * 43 | * @attribute req 44 | * 45 | * @type {Object} 46 | */ 47 | this.req = req 48 | 49 | this.constructor._readyFns 50 | .filter((fn) => typeof (fn) === 'function') 51 | .forEach((fn) => fn(this)) 52 | } 53 | 54 | /** 55 | * Hydrate the context constructor 56 | * 57 | * @method hydrate 58 | * 59 | * @return {void} 60 | */ 61 | static hydrate () { 62 | super.hydrate() 63 | this._readyFns = [] 64 | } 65 | 66 | /** 67 | * Define onReady callbacks to be executed 68 | * once the request context is instantiated 69 | * 70 | * @method onReady 71 | * 72 | * @param {Function} fn 73 | * 74 | * @chainable 75 | */ 76 | static onReady (fn) { 77 | this._readyFns.push(fn) 78 | return this 79 | } 80 | } 81 | 82 | /** 83 | * Defining _macros and _getters property 84 | * for Macroable class 85 | * 86 | * @type {Object} 87 | */ 88 | WsContext._macros = {} 89 | WsContext._getters = {} 90 | WsContext._readyFns = [] 91 | 92 | module.exports = WsContext 93 | -------------------------------------------------------------------------------- /src/JsonEncoder/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | module.exports = { 13 | name: 'json', 14 | 15 | /** 16 | * Encode a value by stringifying it 17 | * 18 | * @method encode 19 | * 20 | * @param {Object} payload 21 | * @param {Function} callback 22 | * 23 | * @return {void} 24 | */ 25 | encode (payload, callback) { 26 | let encoded = null 27 | 28 | try { 29 | encoded = JSON.stringify(payload) 30 | } catch (error) { 31 | return callback(error) 32 | } 33 | callback(null, encoded) 34 | }, 35 | 36 | /** 37 | * Decode value by parsing it 38 | * 39 | * @method decode 40 | * 41 | * @param {String} payload 42 | * @param {Function} callback 43 | * 44 | * @return {void} 45 | */ 46 | decode (payload, callback) { 47 | let decoded = null 48 | 49 | try { 50 | decoded = JSON.parse(payload) 51 | } catch (error) { 52 | return callback(error) 53 | } 54 | callback(null, decoded) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Middleware/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const MiddlewareBase = require('@adonisjs/middleware-base') 13 | module.exports = new MiddlewareBase('wsHandle') 14 | -------------------------------------------------------------------------------- /src/Socket/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const Emittery = require('emittery') 13 | const debug = require('debug')('adonis:websocket') 14 | const GE = require('@adonisjs/generic-exceptions') 15 | const ClusterHop = require('../ClusterHop') 16 | 17 | /** 18 | * Socket is instance of a subscription for a given topic. 19 | * Socket will always have access to the channel and 20 | * it's parent connection. 21 | * 22 | * @class Socket 23 | * 24 | * @param {String} topic 25 | * @param {Connect} connection 26 | */ 27 | class Socket { 28 | constructor (topic, connection) { 29 | this.channel = null 30 | 31 | /** 32 | * Below properties cannot be changed 33 | */ 34 | Object.defineProperty(this, 'topic', { 35 | get () { return topic } 36 | }) 37 | 38 | Object.defineProperty(this, 'connection', { 39 | get () { return connection } 40 | }) 41 | 42 | Object.defineProperty(this, 'id', { 43 | get () { return `${topic}#${connection.id}` } 44 | }) 45 | 46 | this.emitter = new Emittery() 47 | } 48 | 49 | /** 50 | * Associates the channel to the socket 51 | * 52 | * @method associateChannel 53 | * 54 | * @param {Channel} channel 55 | * 56 | * @return {void} 57 | */ 58 | associateChannel (channel) { 59 | this.channel = channel 60 | } 61 | 62 | /* istanbul ignore next */ 63 | /** 64 | * Bind a listener 65 | * 66 | * @method on 67 | * 68 | * @param {...Spread} args 69 | * 70 | * @return {void} 71 | */ 72 | on (...args) { 73 | return this.emitter.on(...args) 74 | } 75 | 76 | /* istanbul ignore next */ 77 | /** 78 | * Bind a listener for one time only 79 | * 80 | * @method once 81 | * 82 | * @param {...Spread} args 83 | * 84 | * @return {void} 85 | */ 86 | once (...args) { 87 | return this.emitter.once(...args) 88 | } 89 | 90 | /* istanbul ignore next */ 91 | /** 92 | * Remove listener 93 | * 94 | * @method off 95 | * 96 | * @param {...Spread} args 97 | * 98 | * @return {void} 99 | */ 100 | off (...args) { 101 | return this.emitter.off(...args) 102 | } 103 | 104 | /** 105 | * Emit message to the client 106 | * 107 | * @method emit 108 | * 109 | * @param {String} event 110 | * @param {Object} data 111 | * @param {Function} [ack] 112 | * 113 | * @return {void} 114 | */ 115 | emit (event, data, ack) { 116 | this.connection.sendEvent(this.topic, event, data, ack) 117 | } 118 | 119 | /** 120 | * Broadcast event to everyone except the current socket. 121 | * 122 | * @method broadcast 123 | * 124 | * @param {String} event 125 | * @param {Mixed} data 126 | * 127 | * @return {void} 128 | */ 129 | broadcast (event, data) { 130 | const packet = this.connection.makeEventPacket(this.topic, event, data) 131 | 132 | /** 133 | * Encoding the packet before hand, so that we don't pay the penalty of 134 | * re-encoding the same message again and again 135 | */ 136 | this.connection.encodePacket(packet, (error, payload) => { 137 | if (error) { 138 | return 139 | } 140 | this.channel.broadcastPayload(this.topic, payload, [this.id]) 141 | ClusterHop.send('broadcast', this.topic, payload) 142 | }) 143 | } 144 | 145 | /** 146 | * Broadcasts the message to everyone who has joined the 147 | * current topic. 148 | * 149 | * @method broadcastToAll 150 | * 151 | * @param {String} event 152 | * @param {Mixed} data 153 | * 154 | * @return {void} 155 | */ 156 | broadcastToAll (event, data) { 157 | const packet = this.connection.makeEventPacket(this.topic, event, data) 158 | 159 | /** 160 | * Encoding the packet before hand, so that we don't pay the penalty of 161 | * re-encoding the same message again and again 162 | */ 163 | this.connection.encodePacket(packet, (error, payload) => { 164 | if (error) { 165 | return 166 | } 167 | this.channel.broadcastPayload(this.topic, payload, []) 168 | ClusterHop.send('broadcast', this.topic, payload) 169 | }) 170 | } 171 | 172 | /** 173 | * Emit event to selected socket ids 174 | * 175 | * @method emitTo 176 | * 177 | * @param {String} event 178 | * @param {Mixed} data 179 | * @param {Array} ids 180 | * 181 | * @return {void} 182 | */ 183 | emitTo (event, data, ids) { 184 | if (!Array.isArray(ids)) { 185 | throw GE.InvalidArgumentException.invalidParameter('emitTo expects 3rd parameter to be an array of socket ids', ids) 186 | } 187 | 188 | const packet = this.connection.makeEventPacket(this.topic, event, data) 189 | 190 | /** 191 | * Encoding the packet before hand, so that we don't pay the penalty of 192 | * re-encoding the same message again and again 193 | */ 194 | this.connection.encodePacket(packet, (error, payload) => { 195 | if (error) { 196 | return 197 | } 198 | this.channel.broadcastPayload(this.topic, payload, ids, true) 199 | ClusterHop.send('broadcast', this.topic, payload) 200 | }) 201 | } 202 | 203 | /** 204 | * Invoked when internal connection gets a TCP error 205 | * 206 | * @method serverError 207 | * 208 | * @param {Number} code 209 | * @param {String} reason 210 | * 211 | * @return {void} 212 | */ 213 | serverError (code, reason) { 214 | this.emitter.emit('error', { code, reason }) 215 | } 216 | 217 | /** 218 | * A new message received 219 | * 220 | * @method serverMessage 221 | * 222 | * @param {String} options.event 223 | * @param {Mixed} options.data 224 | * 225 | * @return {void} 226 | */ 227 | serverMessage ({ event, data }) { 228 | this.emitter.emit(event, data) 229 | } 230 | 231 | /** 232 | * Close the subscription, when client asks for it 233 | * or when server connection closes 234 | * 235 | * @method serverClose 236 | * 237 | * @return {Promise} 238 | */ 239 | serverClose () { 240 | return this.emitter 241 | .emit('close', this) 242 | .then(() => { 243 | this.emitter.clearListeners() 244 | }) 245 | .catch(() => { 246 | this.emitter.clearListeners() 247 | }) 248 | } 249 | 250 | /** 251 | * Close the subscription manually 252 | * 253 | * @method close 254 | * 255 | * @return {Promise} 256 | */ 257 | close () { 258 | debug('self closing subscription for %s topic', this.topic) 259 | 260 | return this 261 | .serverClose() 262 | .then(() => { 263 | this.connection.sendLeavePacket(this.topic) 264 | }) 265 | } 266 | } 267 | 268 | module.exports = Socket 269 | -------------------------------------------------------------------------------- /src/Ws/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const WebSocket = require('ws') 13 | const url = require('url') 14 | const GE = require('@adonisjs/generic-exceptions') 15 | const Connection = require('../Connection') 16 | const ClusterHop = require('../ClusterHop') 17 | const ChannelManager = require('../Channel/Manager') 18 | const JsonEncoder = require('../JsonEncoder') 19 | const middleware = require('../Middleware') 20 | 21 | /** 22 | * The websocket server is a wrapper over `ws` node library and 23 | * written for AdonisJs specifically. 24 | * 25 | * @class Ws 26 | * 27 | * @package {Config} Config - Reference of Config provider 28 | */ 29 | class Ws { 30 | constructor (Config) { 31 | this._options = Config.merge('socket', { 32 | path: '/adonis-ws', 33 | serverInterval: 30000, 34 | serverAttempts: 3, 35 | clientInterval: 25000, 36 | clientAttempts: 3, 37 | encoder: JsonEncoder 38 | }) 39 | 40 | /** 41 | * These options are passed directly to the `Websocket.Server` constructor 42 | * 43 | * @type {Object} 44 | */ 45 | this._serverOptions = { 46 | path: this._options.path, 47 | verifyClient: this._verifyClient.bind(this) 48 | } 49 | 50 | /** 51 | * The function to be called on connection 52 | * handshake 53 | * 54 | * @type {Function} 55 | */ 56 | this._handshakeFn = null 57 | 58 | /** 59 | * Reference to actual websocket server. It will 60 | * be set when `listen` method is called. 61 | * 62 | * @type {Websocket.Server} 63 | */ 64 | this._wsServer = null 65 | 66 | /** 67 | * Encoder to be used for encoding the messages 68 | * 69 | * @type {Encoders} 70 | */ 71 | this._encoder = this._options.encoder 72 | 73 | /** 74 | * Tracking all the connections, this is required to play 75 | * ping/pong 76 | * 77 | * @type {Set} 78 | */ 79 | this._connections = new Set() 80 | 81 | /** 82 | * The timer initiated for monitoring connections 83 | * and terminating them if they are dead. 84 | * 85 | * @type {Timer} 86 | */ 87 | this._heartBeatTimer = null 88 | } 89 | 90 | /** 91 | * Verifies the handshake of a new connection. 92 | * 93 | * @method _verifyClient 94 | * 95 | * @param {Object} info 96 | * @param {Function} ack 97 | * 98 | * @return {void} 99 | * 100 | * @private 101 | */ 102 | async _verifyClient (info, ack) { 103 | if (typeof (this._handshakeFn) !== 'function') { 104 | return ack(true) 105 | } 106 | 107 | try { 108 | await this._handshakeFn(info) 109 | ack(true) 110 | } catch (error) { 111 | ack(false, error.status, error.message) 112 | } 113 | } 114 | 115 | /** 116 | * The heart bear timer is required to monitor the health 117 | * of connections. 118 | * 119 | * Server will create only one timer for all the connections. 120 | * 121 | * @method _registerTimer 122 | * 123 | * @return {void} 124 | * 125 | * @private 126 | */ 127 | _registerTimer () { 128 | this._heartBeatTimer = setInterval(() => { 129 | this._connections.forEach((connection) => { 130 | if (connection.pingElapsed >= this._options.serverAttempts) { 131 | connection.terminate('ping elapsed') 132 | } else { 133 | connection.pingElapsed++ 134 | } 135 | }) 136 | }, this._options.serverInterval) 137 | } 138 | 139 | /** 140 | * Clearing the timer when server closes 141 | * 142 | * @method _clearTimer 143 | * 144 | * @return {void} 145 | * 146 | * @private 147 | */ 148 | _clearTimer () { 149 | clearInterval(this._heartBeatTimer) 150 | } 151 | 152 | /** 153 | * Bind a single function to validate the handshakes 154 | * 155 | * @method onHandshake 156 | * 157 | * @param {Function} fn 158 | * 159 | * @chainable 160 | */ 161 | onHandshake (fn) { 162 | if (typeof (fn) !== 'function') { 163 | throw GE.InvalidArgumentException.invalidParameter('Ws.onHandshake accepts a function', fn) 164 | } 165 | 166 | this._handshakeFn = fn 167 | return this 168 | } 169 | 170 | /** 171 | * Register a new channel to accept topic subscriptions 172 | * 173 | * @method channel 174 | * 175 | * @param {...Spread} args 176 | * 177 | * @return {Channel} 178 | */ 179 | channel (...args) { 180 | return ChannelManager.add(...args) 181 | } 182 | 183 | /** 184 | * Returns channel instance for a given channel 185 | * 186 | * @method getChannel 187 | * 188 | * @param {String} name 189 | * 190 | * @return {Channel} 191 | */ 192 | getChannel (name) { 193 | return ChannelManager.get(name) 194 | } 195 | 196 | /** 197 | * Handle a new connection 198 | * 199 | * @method handle 200 | * 201 | * @param {Object} ws 202 | * @param {Object} req 203 | * 204 | * @return {void} 205 | */ 206 | handle (ws, req) { 207 | const connection = new Connection(ws, req, this._encoder) 208 | 209 | /** 210 | * Important to leave the connection instance, when it closes to 211 | * avoid memory leaks. 212 | */ 213 | connection.on('close', (__connection__) => { 214 | this._connections.delete(__connection__) 215 | }) 216 | 217 | /** 218 | * Open packet is an acknowledgement to the client that server is 219 | * ready to accept subscriptions 220 | */ 221 | connection.sendOpenPacket({ 222 | connId: connection.id, 223 | serverInterval: this._options.serverInterval, 224 | serverAttempts: this._options.serverAttempts, 225 | clientInterval: this._options.clientInterval, 226 | clientAttempts: this._options.clientAttempts 227 | }) 228 | 229 | this._connections.add(connection) 230 | } 231 | 232 | /** 233 | * Start the websocket server 234 | * 235 | * @method listen 236 | * 237 | * @param {Http.Server} server 238 | * 239 | * @return {void} 240 | */ 241 | listen (server) { 242 | this._wsServer = new WebSocket.Server(Object.assign({}, this._serverOptions, { server })) 243 | 244 | /** 245 | * Override the shouldHandle method to allow trailing slashes 246 | */ 247 | this._wsServer.shouldHandle = function (req) { 248 | return this.options.path && url.parse(req.url).pathname.replace(/\/$/, '') === this.options.path 249 | } 250 | 251 | /** 252 | * Listening for new connections 253 | */ 254 | this._wsServer.on('connection', this.handle.bind(this)) 255 | 256 | this._registerTimer() 257 | ClusterHop.init() 258 | } 259 | 260 | /** 261 | * Closes the websocket server 262 | * 263 | * @method close 264 | * 265 | * @return {void} 266 | */ 267 | close () { 268 | ChannelManager.clear() 269 | ClusterHop.destroy() 270 | 271 | if (this._wsServer) { 272 | this._connections.forEach((connection) => connection.terminate('closing server')) 273 | this._wsServer.close() 274 | this._clearTimer() 275 | } 276 | } 277 | 278 | /** 279 | * Register an array of global middleware 280 | * 281 | * @method registerGlobal 282 | * 283 | * @param {Array} list 284 | * 285 | * @chainable 286 | * 287 | * @example 288 | * ```js 289 | * Ws.registerGlobal([ 290 | * 'Adonis/Middleware/AuthInit' 291 | * ]) 292 | * ``` 293 | */ 294 | registerGlobal (list) { 295 | middleware.registerGlobal(list) 296 | return this 297 | } 298 | 299 | /** 300 | * Register a list of named middleware 301 | * 302 | * @method registerNamed 303 | * 304 | * @param {Object} list 305 | * 306 | * @chainable 307 | * 308 | * ```js 309 | * Ws.registerNamed({ 310 | * auth: 'Adonis/Middleware/Auth' 311 | * }) 312 | * ``` 313 | */ 314 | registerNamed (list) { 315 | middleware.registerNamed(list) 316 | return this 317 | } 318 | } 319 | 320 | module.exports = Ws 321 | -------------------------------------------------------------------------------- /test/functional/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const path = require('path') 13 | const fold = require('@adonisjs/fold') 14 | const { Helpers } = require('@adonisjs/sink') 15 | 16 | module.exports = async () => { 17 | process.env.ENV_SILENT = true 18 | 19 | fold.ioc.singleton('Adonis/Src/Helpers', () => { 20 | return new Helpers(__dirname) 21 | }) 22 | 23 | fold.registrar.providers([ 24 | '@adonisjs/framework/providers/AppProvider', 25 | path.join(__dirname, '..', '..', 'providers', 'WsProvider') 26 | ]) 27 | 28 | await fold.registrar.registerAndBoot() 29 | } 30 | -------------------------------------------------------------------------------- /test/functional/ws.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const test = require('japa') 13 | const { ioc } = require('@adonisjs/fold') 14 | const msp = require('@adonisjs/websocket-packet') 15 | 16 | const setup = require('./setup') 17 | const helpers = require('../helpers') 18 | const Socket = require('../../src/Socket') 19 | 20 | test.group('Ws', (group) => { 21 | group.before(async () => { 22 | await setup() 23 | }) 24 | 25 | group.beforeEach(() => { 26 | this.Ws = ioc.use('Ws') 27 | this.Ws._options.serverInterval = 1000 28 | this.Ws._options.serverAttempts = 1 29 | this.httpServer = helpers.startHttpServer() 30 | this.Ws.listen(this.httpServer) 31 | }) 32 | 33 | group.afterEach(() => { 34 | this.httpServer.close() 35 | this.Ws.close() 36 | }) 37 | 38 | test('make a socket connection', (assert, done) => { 39 | const client = helpers.startClient({}, '/adonis-ws') 40 | client.on('open', () => { 41 | done() 42 | }) 43 | }) 44 | 45 | test('send open packet when client joins', (assert, done) => { 46 | const client = helpers.startClient({}, '/adonis-ws') 47 | client.on('message', (payload) => { 48 | done(() => { 49 | const actualPacket = JSON.parse(payload) 50 | 51 | assert.deepEqual(actualPacket, { 52 | t: msp.codes.OPEN, 53 | d: { 54 | connId: actualPacket.d.connId, 55 | serverInterval: this.Ws._options.serverInterval, 56 | serverAttempts: this.Ws._options.serverAttempts, 57 | clientInterval: this.Ws._options.clientInterval, 58 | clientAttempts: this.Ws._options.clientAttempts 59 | } 60 | }) 61 | }) 62 | }) 63 | }) 64 | 65 | test('return error when trying to join un-registered channel', (assert, done) => { 66 | const client = helpers.startClient({}, '/adonis-ws') 67 | 68 | client.on('message', (payload) => { 69 | const actualPacket = JSON.parse(payload) 70 | 71 | if (actualPacket.t === msp.codes.JOIN_ERROR) { 72 | done(() => { 73 | const expectedPacket = msp.joinErrorPacket('chat', 'Topic cannot be handled by any channel') 74 | assert.deepEqual(actualPacket, expectedPacket) 75 | }) 76 | } 77 | }) 78 | 79 | client.on('open', () => { 80 | client.send(JSON.stringify(msp.joinPacket('chat'))) 81 | }) 82 | }) 83 | 84 | test('return error when topic is missing in join packet', (assert, done) => { 85 | const client = helpers.startClient({}, '/adonis-ws') 86 | 87 | client.on('message', (payload) => { 88 | const actualPacket = JSON.parse(payload) 89 | 90 | if (actualPacket.t === msp.codes.JOIN_ERROR) { 91 | done(() => { 92 | const expectedPacket = msp.joinErrorPacket('unknown', 'Missing topic name') 93 | assert.deepEqual(actualPacket, expectedPacket) 94 | }) 95 | } 96 | }) 97 | 98 | client.on('open', () => { 99 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: {} })) 100 | }) 101 | }) 102 | 103 | test('join topic when channel is registered to handle it', (assert, done) => { 104 | const client = helpers.startClient({}, '/adonis-ws') 105 | let connectedSocket = null 106 | 107 | this.Ws.channel('chat', ({ socket }) => { 108 | connectedSocket = socket 109 | }) 110 | 111 | client.on('message', (payload) => { 112 | const actualPacket = JSON.parse(payload) 113 | 114 | if (actualPacket.t === msp.codes.JOIN_ACK) { 115 | done(() => { 116 | const expectedPacket = msp.joinAckPacket('chat') 117 | assert.deepEqual(actualPacket, expectedPacket) 118 | assert.instanceOf(connectedSocket, Socket) 119 | }) 120 | } 121 | }) 122 | 123 | client.on('open', () => { 124 | client.send(JSON.stringify(msp.joinPacket('chat'))) 125 | }) 126 | }) 127 | 128 | test('return error when joining the same topic twice on a single connection', (assert, done) => { 129 | const client = helpers.startClient({}, '/adonis-ws') 130 | let connectedSocket = null 131 | 132 | this.Ws.channel('chat', ({ socket }) => { 133 | connectedSocket = socket 134 | }) 135 | 136 | client.on('message', (payload) => { 137 | const actualPacket = JSON.parse(payload) 138 | if (actualPacket.t === msp.codes.JOIN_ERROR) { 139 | done(() => { 140 | const expectedPacket = msp.joinErrorPacket('chat', 'Cannot join the same topic twice') 141 | assert.deepEqual(actualPacket, expectedPacket) 142 | assert.instanceOf(connectedSocket, Socket) 143 | }) 144 | } 145 | }) 146 | 147 | client.on('open', () => { 148 | client.send(JSON.stringify(msp.joinPacket('chat'))) 149 | client.send(JSON.stringify(msp.joinPacket('chat'))) 150 | }) 151 | }) 152 | 153 | test('join the same topic twice on different single connections', (assert, done) => { 154 | let connectedSockets = [] 155 | let replyPackets = [] 156 | 157 | this.Ws.channel('chat', ({ socket }) => { 158 | connectedSockets.push(socket) 159 | }) 160 | 161 | const handleMessage = function (payload) { 162 | const packet = JSON.parse(payload) 163 | if (packet.t === msp.codes.OPEN) { 164 | return 165 | } 166 | 167 | replyPackets.push(packet) 168 | if (replyPackets.length === 2) { 169 | done(() => { 170 | const expectedPacket = msp.joinAckPacket('chat') 171 | assert.deepEqual(replyPackets[0], expectedPacket) 172 | assert.deepEqual(replyPackets[1], expectedPacket) 173 | assert.instanceOf(connectedSockets[0], Socket) 174 | assert.instanceOf(connectedSockets[1], Socket) 175 | }) 176 | } 177 | } 178 | 179 | const client = helpers.startClient({}, '/adonis-ws') 180 | const client1 = helpers.startClient({}, '/adonis-ws') 181 | client.on('message', handleMessage) 182 | client1.on('message', handleMessage) 183 | 184 | client.on('open', () => { 185 | client.send(JSON.stringify(msp.joinPacket('chat'))) 186 | }) 187 | 188 | client1.on('open', () => { 189 | client1.send(JSON.stringify(msp.joinPacket('chat'))) 190 | }) 191 | }) 192 | 193 | test('cleanup all listeners and subscriptions when connection closes', (assert, done) => { 194 | let connectedSockets = {} 195 | let replyPackets = {} 196 | 197 | this.Ws.channel('chat:*', ({ socket }) => { 198 | connectedSockets[socket.topic] = socket 199 | }) 200 | 201 | const client = helpers.startClient({}, '/adonis-ws') 202 | client.on('message', (payload) => { 203 | const packet = JSON.parse(payload) 204 | if (packet.t === msp.codes.OPEN) { 205 | return 206 | } 207 | 208 | replyPackets[packet.d.topic] = packet 209 | if (Object.keys(replyPackets).length === 2) { 210 | client.close() 211 | } 212 | }) 213 | 214 | client.on('close', () => { 215 | setTimeout(() => { 216 | done(() => { 217 | assert.equal(this.Ws._connections.size, 0) 218 | assert.deepEqual(connectedSockets['chat:watercooler'].connection, connectedSockets['chat:frontend'].connection) 219 | assert.equal(connectedSockets['chat:watercooler'].connection._subscriptions.size, 0) 220 | assert.equal(connectedSockets['chat:watercooler'].connection.listenerCount(), 0) 221 | assert.equal(connectedSockets['chat:watercooler'].emitter.listenerCount(), 0) 222 | assert.equal(connectedSockets['chat:frontend'].emitter.listenerCount(), 0) 223 | assert.deepEqual(replyPackets, { 224 | 'chat:watercooler': msp.joinAckPacket('chat:watercooler'), 225 | 'chat:frontend': msp.joinAckPacket('chat:frontend') 226 | }) 227 | }) 228 | }, 500) 229 | }) 230 | 231 | client.on('open', () => { 232 | client.send(JSON.stringify(msp.joinPacket('chat:watercooler'))) 233 | client.send(JSON.stringify(msp.joinPacket('chat:frontend'))) 234 | }) 235 | }) 236 | 237 | test('cleanup connection when client doesn\'t ping within pingInterval', (assert, done) => { 238 | let connectedSockets = {} 239 | let replyPackets = {} 240 | 241 | this.Ws.channel('chat:*', ({ socket }) => { 242 | connectedSockets[socket.topic] = socket 243 | }) 244 | 245 | const client = helpers.startClient({}, '/adonis-ws') 246 | client.on('message', (payload) => { 247 | const packet = JSON.parse(payload) 248 | if (packet.t === msp.codes.OPEN) { 249 | return 250 | } 251 | replyPackets[packet.d.topic] = packet 252 | }) 253 | 254 | client.on('close', () => { 255 | setTimeout(() => { 256 | done(() => { 257 | assert.equal(this.Ws._connections.size, 0) 258 | assert.deepEqual(connectedSockets['chat:watercooler'].connection, connectedSockets['chat:frontend'].connection) 259 | assert.equal(connectedSockets['chat:watercooler'].connection._subscriptions.size, 0) 260 | assert.equal(connectedSockets['chat:watercooler'].connection.listenerCount(), 0) 261 | assert.equal(connectedSockets['chat:watercooler'].emitter.listenerCount(), 0) 262 | assert.equal(connectedSockets['chat:frontend'].emitter.listenerCount(), 0) 263 | assert.deepEqual(replyPackets, { 264 | 'chat:watercooler': msp.joinAckPacket('chat:watercooler'), 265 | 'chat:frontend': msp.joinAckPacket('chat:frontend') 266 | }) 267 | }) 268 | }, 500) 269 | }) 270 | 271 | client.on('open', () => { 272 | client.send(JSON.stringify(msp.joinPacket('chat:watercooler'))) 273 | client.send(JSON.stringify(msp.joinPacket('chat:frontend'))) 274 | }) 275 | }).timeout(5000) 276 | 277 | test('should have access to request object on context', (assert, done) => { 278 | this.Ws.channel('chat', ({ request }) => { 279 | assert.instanceOf(request, require('@adonisjs/framework/src/Request')) 280 | done() 281 | }) 282 | 283 | const client = helpers.startClient({}, '/adonis-ws') 284 | client.on('open', () => { 285 | client.send(JSON.stringify(msp.joinPacket('chat'))) 286 | }) 287 | }) 288 | 289 | test('broadcast messages to multiple clients', (assert, done) => { 290 | let connectedClients = [] 291 | let receivedMessages = [] 292 | 293 | this.Ws.channel('chat', ({ socket }) => { 294 | connectedClients.push(socket) 295 | if (connectedClients.length === 3) { 296 | connectedClients[0].broadcast('greeting', { hello: 'world' }) 297 | } 298 | }) 299 | 300 | function joinChannel (client) { 301 | client.send(JSON.stringify(msp.joinPacket('chat'))) 302 | } 303 | 304 | function onMessage (payload) { 305 | const packet = JSON.parse(payload) 306 | if (packet.t === msp.codes.EVENT) { 307 | receivedMessages.push(packet.d) 308 | } 309 | 310 | if (receivedMessages.length === 2) { 311 | done(() => { 312 | assert.deepEqual(receivedMessages, [ 313 | { 314 | event: 'greeting', 315 | data: { hello: 'world' }, 316 | topic: 'chat' 317 | }, 318 | { 319 | event: 'greeting', 320 | data: { hello: 'world' }, 321 | topic: 'chat' 322 | } 323 | ]) 324 | }) 325 | } 326 | } 327 | 328 | const client = helpers.startClient({}, '/adonis-ws') 329 | client.on('open', () => joinChannel(client)) 330 | 331 | const client1 = helpers.startClient({}, '/adonis-ws') 332 | client1.on('open', () => joinChannel(client1)) 333 | client1.on('message', onMessage) 334 | 335 | const client2 = helpers.startClient({}, '/adonis-ws') 336 | client2.on('open', () => joinChannel(client2)) 337 | client2.on('message', onMessage) 338 | }).timeout(0) 339 | 340 | test('broadcast messages to all clients', (assert, done) => { 341 | let connectedClients = [] 342 | let receivedMessages = [] 343 | 344 | this.Ws.channel('chat', ({ socket }) => { 345 | connectedClients.push(socket) 346 | if (connectedClients.length === 3) { 347 | connectedClients[0].broadcastToAll('greeting', { hello: 'world' }) 348 | } 349 | }) 350 | 351 | function joinChannel (client) { 352 | client.send(JSON.stringify(msp.joinPacket('chat'))) 353 | } 354 | 355 | function onMessage (payload) { 356 | const packet = JSON.parse(payload) 357 | if (packet.t === msp.codes.EVENT) { 358 | receivedMessages.push(packet.d) 359 | } 360 | 361 | if (receivedMessages.length === 3) { 362 | done(() => { 363 | assert.deepEqual(receivedMessages, [ 364 | { 365 | event: 'greeting', 366 | data: { hello: 'world' }, 367 | topic: 'chat' 368 | }, 369 | { 370 | event: 'greeting', 371 | data: { hello: 'world' }, 372 | topic: 'chat' 373 | }, 374 | { 375 | event: 'greeting', 376 | data: { hello: 'world' }, 377 | topic: 'chat' 378 | } 379 | ]) 380 | }) 381 | } 382 | } 383 | 384 | const client = helpers.startClient({}, '/adonis-ws') 385 | client.on('open', () => joinChannel(client)) 386 | client.on('message', onMessage) 387 | 388 | const client1 = helpers.startClient({}, '/adonis-ws') 389 | client1.on('open', () => joinChannel(client1)) 390 | client1.on('message', onMessage) 391 | 392 | const client2 = helpers.startClient({}, '/adonis-ws') 393 | client2.on('open', () => joinChannel(client2)) 394 | client2.on('message', onMessage) 395 | }) 396 | 397 | test('only broadcast within the same channel', (assert, done) => { 398 | let connectedClients = { 399 | 'chat:watercooler': [], 400 | 'chat:frontend': [] 401 | } 402 | let receivedMessages = [] 403 | 404 | this.Ws.channel('chat:*', ({ socket }) => { 405 | connectedClients[socket.topic].push(socket) 406 | 407 | if (connectedClients['chat:watercooler'].length === 2 && connectedClients['chat:frontend'].length === 1) { 408 | connectedClients['chat:watercooler'][0].broadcastToAll('greeting', { hello: 'world' }) 409 | } 410 | }) 411 | 412 | function joinChannel (client, topic) { 413 | client.send(JSON.stringify(msp.joinPacket(topic))) 414 | } 415 | 416 | function onMessage (payload) { 417 | const packet = JSON.parse(payload) 418 | if (packet.t === msp.codes.EVENT) { 419 | receivedMessages.push(packet.d) 420 | } 421 | 422 | if (receivedMessages.length === 2) { 423 | done(() => { 424 | assert.deepEqual(receivedMessages, [ 425 | { 426 | event: 'greeting', 427 | data: { hello: 'world' }, 428 | topic: 'chat:watercooler' 429 | }, 430 | { 431 | event: 'greeting', 432 | data: { hello: 'world' }, 433 | topic: 'chat:watercooler' 434 | } 435 | ]) 436 | }) 437 | } 438 | } 439 | 440 | const client = helpers.startClient({}, '/adonis-ws') 441 | client.on('open', () => joinChannel(client, 'chat:watercooler')) 442 | client.on('message', onMessage) 443 | 444 | const client1 = helpers.startClient({}, '/adonis-ws') 445 | client1.on('open', () => joinChannel(client1, 'chat:watercooler')) 446 | client1.on('message', onMessage) 447 | 448 | const client2 = helpers.startClient({}, '/adonis-ws') 449 | client2.on('open', () => joinChannel(client2, 'chat:frontend')) 450 | client2.on('message', onMessage) 451 | }) 452 | 453 | test('work fine with slash in the end', (assert, done) => { 454 | const client = helpers.startClient({}, '/adonis-ws/') 455 | client.on('open', () => { 456 | done() 457 | }) 458 | }) 459 | }) 460 | -------------------------------------------------------------------------------- /test/helpers/Request.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const url = require('url') 4 | const querystring = require('querystring') 5 | 6 | class Request { 7 | constructor (req, res) { 8 | if (!req || !res) { 9 | throw new Error('req and res are required') 10 | } 11 | this.request = req 12 | this.response = res 13 | } 14 | 15 | input (key) { 16 | const query = url.parse(this.request.url).search 17 | if (!query) { 18 | return null 19 | } 20 | return querystring.parse(query.replace(/^\?/, ''))[key] 21 | } 22 | } 23 | 24 | module.exports = Request 25 | -------------------------------------------------------------------------------- /test/helpers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const WebSocket = require('ws') 13 | const http = require('http') 14 | const cuid = require('cuid') 15 | const querystring = require('querystring') 16 | 17 | module.exports = { 18 | startClient (query = {}, path = '/', port = 8000) { 19 | let url = `http://localhost:${port}${path}` 20 | url = query && Object.keys(query).length ? `${url}?${querystring.stringify(query)}` : url 21 | return new WebSocket(url) 22 | }, 23 | 24 | startHttpServer (port = 8000) { 25 | const httpServer = http.createServer((req, res) => {}) 26 | httpServer.listen(port) 27 | return httpServer 28 | }, 29 | 30 | startWsServer (port) { 31 | return new WebSocket.Server({ 32 | server: this.startHttpServer(port) 33 | }) 34 | }, 35 | 36 | getFakeChannel () { 37 | return class FakeChannel { 38 | constructor (name) { 39 | this.subscriptions = new Map() 40 | this.name = name 41 | } 42 | 43 | broadcastPayload (topic, payload, filterSockets = []) { 44 | this.subscriptions.get(topic).forEach((socket) => { 45 | if (filterSockets.indexOf(socket.id) === -1) { 46 | socket.connection.write(payload) 47 | } 48 | }) 49 | } 50 | } 51 | }, 52 | 53 | getFakeConnection () { 54 | return class FakeConnection { 55 | constructor (id) { 56 | this.id = id || cuid() 57 | } 58 | 59 | encodePacket (message, cb) { 60 | cb(null, message) 61 | } 62 | 63 | makeEventPacket (topic, event, data) { 64 | return { topic, event, data } 65 | } 66 | 67 | sendLeavePacket () { 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/unit/channel.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const test = require('japa') 13 | const { setupResolver } = require('@adonisjs/sink') 14 | const { ioc } = require('@adonisjs/fold') 15 | 16 | const Channel = require('../../src/Channel') 17 | const Manager = require('../../src/Channel/Manager') 18 | const Socket = require('../../src/Socket') 19 | const middleware = require('../../src/Middleware') 20 | 21 | const helpers = require('../helpers') 22 | const FakeConnection = helpers.getFakeConnection() 23 | 24 | test.group('Channel', (group) => { 25 | group.beforeEach(() => { 26 | ioc.restore() 27 | middleware._middleware.global = [] 28 | middleware._middleware.named = {} 29 | }) 30 | 31 | group.before(() => { 32 | setupResolver() 33 | }) 34 | 35 | test('throw exception when channel doesn\'t have a name', (assert) => { 36 | const channel = () => new Channel() 37 | assert.throw(channel, 'E_INVALID_PARAMETER: Expected channel name to be string') 38 | }) 39 | 40 | test('throw exception when channel doesn\'t have a onConnect callback', (assert) => { 41 | const channel = () => new Channel('foo') 42 | assert.throw(channel, 'E_INVALID_PARAMETER: Expected channel callback to be a function') 43 | }) 44 | 45 | test('bind channel controller when a string is passed', (assert, done) => { 46 | assert.plan(1) 47 | const ctx = { 48 | socket: new Socket('chat', new FakeConnection()) 49 | } 50 | 51 | class ChatController { 52 | constructor (__ctx__) { 53 | assert.deepEqual(__ctx__, ctx) 54 | done() 55 | } 56 | } 57 | 58 | ioc.bind('App/Controllers/Ws/ChatController', () => { 59 | return ChatController 60 | }) 61 | 62 | const channel = new Channel('chat', 'ChatController') 63 | channel.joinTopic(ctx) 64 | }) 65 | 66 | test('bind listeners automatically that are defined on the controller', (assert, done) => { 67 | assert.plan(2) 68 | 69 | const ctx = { 70 | socket: new Socket('chat', new FakeConnection()) 71 | } 72 | 73 | class ChatController { 74 | onMessage () { 75 | } 76 | 77 | onClose () { 78 | } 79 | } 80 | 81 | ioc.bind('App/Controllers/Ws/ChatController', () => { 82 | return ChatController 83 | }) 84 | 85 | const channel = new Channel('chat', 'ChatController') 86 | channel 87 | .joinTopic(ctx) 88 | .then(() => { 89 | process.nextTick(() => { 90 | done(() => { 91 | assert.equal(ctx.socket.emitter.listenerCount('message'), 1) 92 | assert.equal(ctx.socket.emitter.listenerCount('close'), 2) 93 | }) 94 | }) 95 | }) 96 | }) 97 | 98 | test('save topic and socket reference onJoin call', async (assert) => { 99 | const channel = new Channel('foo', function () {}) 100 | const ctx = { 101 | socket: new Socket('foo', new FakeConnection()) 102 | } 103 | await channel.joinTopic(ctx) 104 | assert.deepEqual(channel.subscriptions.get('foo'), new Set([ctx.socket])) 105 | }) 106 | 107 | test('save topic and multiple socket references', async (assert) => { 108 | const channel = new Channel('foo', function () {}) 109 | const ctx = { 110 | socket: new Socket('foo', new FakeConnection()) 111 | } 112 | 113 | const ctx1 = { 114 | socket: new Socket('foo', new FakeConnection()) 115 | } 116 | 117 | await channel.joinTopic(ctx) 118 | await channel.joinTopic(ctx1) 119 | assert.deepEqual(channel.subscriptions.get('foo'), new Set([ctx.socket, ctx1.socket])) 120 | }) 121 | 122 | test('adding subscription to same topic for multiple times must have no impact', async (assert) => { 123 | const channel = new Channel('foo', function () {}) 124 | const ctx = { 125 | socket: new Socket('foo', new FakeConnection()) 126 | } 127 | 128 | await channel.joinTopic(ctx) 129 | await channel.joinTopic(ctx) 130 | assert.deepEqual(channel.subscriptions.get('foo'), new Set([ctx.socket])) 131 | }) 132 | 133 | test('call channel onConnect fn when channel topic is joined', (assert, done) => { 134 | assert.plan(1) 135 | 136 | const ctx = { 137 | socket: new Socket('foo', new FakeConnection()) 138 | } 139 | const channel = new Channel('foo', function (context) { 140 | done(() => { 141 | assert.deepEqual(context, ctx) 142 | }) 143 | }) 144 | channel.joinTopic(ctx) 145 | }) 146 | 147 | test('remove socket reference when leaveTopic is called', async (assert) => { 148 | const ctx = { 149 | socket: new Socket('foo', new FakeConnection()) 150 | } 151 | 152 | const channel = new Channel('foo', function () {}) 153 | await channel.joinTopic(ctx) 154 | 155 | assert.equal(channel.subscriptions.size, 1) 156 | assert.equal(channel.subscriptions.get('foo').size, 1) 157 | 158 | channel.deleteSubscription(ctx.socket) 159 | assert.equal(channel.subscriptions.size, 1) 160 | assert.equal(channel.subscriptions.get('foo').size, 0) 161 | }) 162 | 163 | test('execute middleware before joiningTopic', (assert, done) => { 164 | const ctx = { 165 | socket: new Socket('foo', new FakeConnection()), 166 | joinStack: [] 167 | } 168 | 169 | const channel = new Channel('foo', function (__ctx__) { 170 | __ctx__.joinStack.push(3) 171 | }) 172 | 173 | channel 174 | .middleware(async (__ctx__, next) => { 175 | __ctx__.joinStack.push(1) 176 | await next() 177 | }) 178 | .middleware(async (__ctx__, next) => { 179 | __ctx__.joinStack.push(2) 180 | await next() 181 | }) 182 | 183 | channel 184 | .joinTopic(ctx) 185 | .then(() => { 186 | process.nextTick(() => { 187 | assert.deepEqual(ctx.joinStack, [1, 2, 3]) 188 | done() 189 | }) 190 | }) 191 | }) 192 | 193 | test('do not join topic when middleware throws exception', async (assert) => { 194 | assert.plan(2) 195 | 196 | const ctx = { 197 | socket: new Socket('foo', new FakeConnection()) 198 | } 199 | 200 | const channel = new Channel('foo', function () {}) 201 | 202 | channel 203 | .middleware(async (__ctx__) => { 204 | throw new Error('Cannot join topic') 205 | }) 206 | 207 | try { 208 | await channel.joinTopic(ctx) 209 | } catch ({ message }) { 210 | assert.equal(channel.subscriptions.size, 0) 211 | assert.equal(message, 'Cannot join topic') 212 | } 213 | }) 214 | 215 | test('write payload to socket connection when broadcast is invoked', (assert, done) => { 216 | assert.plan(1) 217 | 218 | const connection = new FakeConnection() 219 | connection.write = function (payload) { 220 | assert.equal(payload, 'hello') 221 | done() 222 | } 223 | 224 | const ctx = { 225 | socket: new Socket('foo', connection) 226 | } 227 | 228 | const channel = new Channel('foo', function () {}) 229 | channel 230 | .joinTopic(ctx) 231 | .then(() => { 232 | channel.broadcastPayload('foo', 'hello') 233 | }) 234 | }) 235 | 236 | test('ignore when broadcast is called for a topic, which has zero subscriptions', (assert, done) => { 237 | const connection = new FakeConnection() 238 | connection.write = function (payload) { 239 | assert.throw('Never expected to be called') 240 | } 241 | 242 | const ctx = { 243 | socket: new Socket('foo', connection) 244 | } 245 | 246 | const channel = new Channel('foo', function () {}) 247 | channel 248 | .joinTopic(ctx) 249 | .then(() => { 250 | channel.broadcastPayload('bar', 'hello') 251 | setTimeout(() => { 252 | done() 253 | }, 200) 254 | }) 255 | }) 256 | 257 | test('pass named middleware reference to channel', (assert, done) => { 258 | const ctx = { 259 | socket: new Socket('foo', new FakeConnection()), 260 | joinStack: [] 261 | } 262 | 263 | ioc.fake('Adonis/Middleware/Foo', () => { 264 | class Foo { 265 | async wsHandle (__ctx__, next) { 266 | __ctx__.joinStack.push(1) 267 | await next() 268 | } 269 | } 270 | return new Foo() 271 | }) 272 | 273 | ioc.fake('Adonis/Middleware/Bar', () => { 274 | class Bar { 275 | async wsHandle (__ctx__, next) { 276 | __ctx__.joinStack.push(2) 277 | await next() 278 | } 279 | } 280 | return new Bar() 281 | }) 282 | 283 | middleware.registerNamed({ 284 | foo: 'Adonis/Middleware/Foo', 285 | bar: 'Adonis/Middleware/Bar' 286 | }) 287 | 288 | const channel = new Channel('foo', function (__ctx__) { 289 | __ctx__.joinStack.push(3) 290 | }) 291 | 292 | channel.middleware(['foo', 'bar']) 293 | 294 | channel 295 | .joinTopic(ctx) 296 | .then(() => { 297 | process.nextTick(() => { 298 | assert.deepEqual(ctx.joinStack, [1, 2, 3]) 299 | done() 300 | }) 301 | }) 302 | .catch(done) 303 | }) 304 | 305 | test('pass runtime params to middleware', (assert, done) => { 306 | const ctx = { 307 | socket: new Socket('foo', new FakeConnection()), 308 | joinStack: [] 309 | } 310 | 311 | ioc.fake('Adonis/Middleware/Foo', () => { 312 | class Foo { 313 | async wsHandle (__ctx__, next, params) { 314 | __ctx__.joinStack = __ctx__.joinStack.concat(params) 315 | await next() 316 | } 317 | } 318 | return new Foo() 319 | }) 320 | 321 | middleware.registerNamed({ 322 | foo: 'Adonis/Middleware/Foo' 323 | }) 324 | 325 | const channel = new Channel('foo', function (__ctx__) { 326 | __ctx__.joinStack.push('done') 327 | }) 328 | 329 | channel.middleware(['foo:first', 'foo:second']) 330 | 331 | channel 332 | .joinTopic(ctx) 333 | .then(() => { 334 | process.nextTick(() => { 335 | done(() => { 336 | assert.deepEqual(ctx.joinStack, ['first', 'second', 'done']) 337 | }) 338 | }) 339 | }) 340 | .catch(done) 341 | }) 342 | 343 | test('broadcast message inside a topic', (assert, done) => { 344 | assert.plan(1) 345 | 346 | const connection = new FakeConnection() 347 | connection.write = function (payload) { 348 | done(() => { 349 | assert.deepEqual(payload, { topic: 'foo', event: 'greeting', data: 'hello' }) 350 | }) 351 | } 352 | 353 | const ctx = { 354 | socket: new Socket('foo', connection) 355 | } 356 | 357 | const channel = new Channel('foo', function () {}) 358 | channel 359 | .joinTopic(ctx) 360 | .then(() => { 361 | channel.topic('foo').broadcastToAll('greeting', 'hello') 362 | }) 363 | }) 364 | 365 | test('broadcast message to all sockets', (assert, done) => { 366 | assert.plan(1) 367 | const payloads = [] 368 | 369 | const write = function (payload) { 370 | payloads.push(payload) 371 | 372 | if (payloads.length === 2) { 373 | done(() => { 374 | assert.deepEqual(payload, { topic: 'foo', event: 'greeting', data: 'hello' }) 375 | }) 376 | } 377 | } 378 | 379 | const connection = new FakeConnection() 380 | connection.write = write 381 | const ctx = { 382 | socket: new Socket('foo', connection) 383 | } 384 | 385 | const connection1 = new FakeConnection() 386 | connection1.write = write 387 | const ctx1 = { 388 | socket: new Socket('foo', connection) 389 | } 390 | 391 | const channel = new Channel('foo', function () {}) 392 | 393 | channel 394 | .joinTopic(ctx1) 395 | .then(() => { 396 | return channel.joinTopic(ctx) 397 | }) 398 | .then(() => { 399 | channel.topic('foo').broadcastToAll('greeting', 'hello') 400 | }) 401 | }) 402 | 403 | test('broadcast message to only selected socket ids', (assert, done) => { 404 | assert.plan(1) 405 | const write = function (payload) { 406 | done(() => { 407 | assert.deepEqual(payload, { topic: 'foo', event: 'greeting', data: 'hello' }) 408 | }) 409 | } 410 | 411 | const connection = new FakeConnection() 412 | connection.write = write 413 | const ctx = { 414 | socket: new Socket('foo', connection) 415 | } 416 | 417 | const connection1 = new FakeConnection() 418 | connection1.write = function () { 419 | done(new Error('Never expected to be called')) 420 | } 421 | 422 | const ctx1 = { 423 | socket: new Socket('foo', connection) 424 | } 425 | 426 | const channel = new Channel('foo', function () {}) 427 | 428 | channel 429 | .joinTopic(ctx1) 430 | .then(() => { 431 | return channel.joinTopic(ctx) 432 | }) 433 | .then(() => { 434 | channel.topic('foo').emitTo('greeting', 'hello', [ctx.socket.id]) 435 | }) 436 | }) 437 | 438 | test('emit to ignore ids that are unknow', (assert, done) => { 439 | assert.plan(1) 440 | const write = function (payload) { 441 | done(() => { 442 | assert.deepEqual(payload, { topic: 'foo', event: 'greeting', data: 'hello' }) 443 | }) 444 | } 445 | 446 | const connection = new FakeConnection() 447 | connection.write = write 448 | const ctx = { 449 | socket: new Socket('foo', connection) 450 | } 451 | 452 | const connection1 = new FakeConnection() 453 | connection1.write = function () { 454 | done(new Error('Never expected to be called')) 455 | } 456 | 457 | const ctx1 = { 458 | socket: new Socket('foo', connection) 459 | } 460 | 461 | const channel = new Channel('foo', function () {}) 462 | 463 | channel 464 | .joinTopic(ctx1) 465 | .then(() => { 466 | return channel.joinTopic(ctx) 467 | }) 468 | .then(() => { 469 | channel.topic('foo').emitTo('greeting', 'hello', [20, 40, 60, ctx.socket.id]) 470 | }) 471 | }) 472 | }) 473 | 474 | test.group('Channel Manager', (group) => { 475 | group.afterEach(() => { 476 | Manager.clear() 477 | }) 478 | 479 | test('add a new channel', (assert) => { 480 | Manager.add('chat', function () {}) 481 | assert.instanceOf(Manager.channels.get('chat'), Channel) 482 | assert.equal(Manager.channels.get('chat').name, 'chat') 483 | }) 484 | 485 | test('remove starting slash from name', (assert) => { 486 | Manager.add('/chat', function () {}) 487 | assert.instanceOf(Manager.channels.get('chat'), Channel) 488 | assert.equal(Manager.channels.get('chat').name, 'chat') 489 | }) 490 | 491 | test('remove trailing slash', (assert) => { 492 | Manager.add('chat/', function () {}) 493 | assert.instanceOf(Manager.channels.get('chat'), Channel) 494 | assert.equal(Manager.channels.get('chat').name, 'chat') 495 | }) 496 | 497 | test('do not remove intermediate slashes', (assert) => { 498 | Manager.add('user/chat', function () {}) 499 | assert.instanceOf(Manager.channels.get('user/chat'), Channel) 500 | assert.equal(Manager.channels.get('user/chat').name, 'user/chat') 501 | }) 502 | 503 | test('generate channel name regex for matching topics', (assert) => { 504 | Manager.add('chat', function () {}) 505 | assert.deepEqual(Manager._channelExpressions, [ 506 | { 507 | expression: /^chat$/, 508 | name: 'chat' 509 | } 510 | ]) 511 | }) 512 | 513 | test('generate channel name regex for wildcard', (assert) => { 514 | Manager.add('chat:*', function () {}) 515 | assert.deepEqual(Manager._channelExpressions, [ 516 | { 517 | expression: /^chat:\w+/, 518 | name: 'chat:*' 519 | } 520 | ]) 521 | }) 522 | 523 | test('only entertain the last wildcard', (assert) => { 524 | Manager.add('chat:*:foo:*', function () {}) 525 | assert.deepEqual(Manager._channelExpressions, [ 526 | { 527 | expression: /^chat:*:foo:\w+/, 528 | name: 'chat:*:foo:*' 529 | } 530 | ]) 531 | }) 532 | 533 | test('resolve channel by matching topic', (assert) => { 534 | const channel = Manager.add('chat/*', function () {}) 535 | assert.deepEqual(Manager.resolve('chat/watercooler'), channel) 536 | }) 537 | 538 | test('return null when unable to resolve topic', (assert) => { 539 | Manager.add('chat:*', function () {}) 540 | assert.isNull(Manager.resolve('foo')) 541 | }) 542 | 543 | test('do not match dynamic topics when wildcard is not defined', (assert) => { 544 | Manager.add('chat', function () {}) 545 | assert.isNull(Manager.resolve('chat:watercooler')) 546 | }) 547 | 548 | test('get channel for a static name', (assert) => { 549 | const channel = Manager.add('chat', function () {}) 550 | assert.deepEqual(Manager.get('chat'), channel) 551 | }) 552 | 553 | test('get channel for a wildcard name', (assert) => { 554 | const channel = Manager.add('chat:*', function () {}) 555 | assert.deepEqual(Manager.get('chat:*'), channel) 556 | }) 557 | }) 558 | -------------------------------------------------------------------------------- /test/unit/cluster-receiver.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const test = require('japa') 13 | const clusterReceiver = require('../../src/ClusterHop/receiver') 14 | const ChannelManager = require('../../src/Channel/Manager') 15 | const stderr = require('test-console').stderr 16 | 17 | test.group('Cluster Receiver', (group) => { 18 | test('ignore message when it\'s not json', (assert) => { 19 | assert.plan(1) 20 | const inspect = stderr.inspect() 21 | 22 | clusterReceiver({ name: 'virk' }) 23 | 24 | inspect.restore() 25 | assert.equal(inspect.output[0].trim(), 'adonis:websocket dropping packet, since not valid json') 26 | }) 27 | 28 | test('ignore message when handle is missing', (assert) => { 29 | assert.plan(1) 30 | const inspect = stderr.inspect() 31 | 32 | clusterReceiver(JSON.stringify({ topic: 'chat' })) 33 | 34 | inspect.restore() 35 | assert.equal(inspect.output[0].trim(), 'adonis:websocket dropping packet, since handle is missing') 36 | }) 37 | 38 | test('ignore message when handle is not one of the allowed handles', (assert) => { 39 | assert.plan(1) 40 | const inspect = stderr.inspect() 41 | 42 | clusterReceiver(JSON.stringify({ topic: 'chat', handle: 'foo' })) 43 | 44 | inspect.restore() 45 | assert.equal(inspect.output[0].trim(), 'adonis:websocket dropping packet, since foo handle is not allowed') 46 | }) 47 | 48 | test('ignore message when topic cannot be handled by any channel', (assert) => { 49 | assert.plan(1) 50 | const inspect = stderr.inspect() 51 | 52 | clusterReceiver(JSON.stringify({ topic: 'chat', handle: 'broadcast' })) 53 | 54 | inspect.restore() 55 | assert.equal(inspect.output[0].trim(), 'adonis:websocket broadcast topic chat cannot be handled by any channel') 56 | }) 57 | 58 | test('send message to channel responsible for handling the topic', (assert, done) => { 59 | assert.plan(2) 60 | const channel = ChannelManager.add('chat', () => {}) 61 | 62 | channel.clusterBroadcast = function (topic, payload) { 63 | assert.equal(topic, 'chat') 64 | assert.equal(payload, 'hello') 65 | done() 66 | } 67 | 68 | clusterReceiver(JSON.stringify({ topic: 'chat', handle: 'broadcast', payload: 'hello' })) 69 | ChannelManager.clear() 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /test/unit/connection.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const test = require('japa') 13 | const msp = require('@adonisjs/websocket-packet') 14 | 15 | const Connection = require('../../src/Connection') 16 | const Manager = require('../../src/Channel/Manager') 17 | const JsonEncoder = require('../../src/JsonEncoder') 18 | 19 | const helpers = require('../helpers') 20 | 21 | test.group('Connection', (group) => { 22 | group.afterEach(async () => { 23 | await new Promise((resolve) => { 24 | Manager.clear() 25 | 26 | if (this.ws) { 27 | this.ws.close(() => { 28 | this.ws.options.server.close(resolve) 29 | }) 30 | } 31 | }) 32 | }) 33 | 34 | test('bind all relevant events on new instance', (assert, done) => { 35 | assert.plan(4) 36 | 37 | this.ws = helpers.startWsServer() 38 | this.ws.on('connection', (ws, req) => { 39 | const connection = new Connection(ws, req, JsonEncoder) 40 | assert.equal(connection.ws._eventsCount, 3) 41 | assert.property(connection.ws._events, 'message') 42 | assert.property(connection.ws._events, 'error') 43 | assert.property(connection.ws._events, 'close') 44 | done() 45 | }) 46 | helpers.startClient() 47 | }) 48 | 49 | test('on connection send open packet', (assert, done) => { 50 | assert.plan(1) 51 | 52 | this.ws = helpers.startWsServer() 53 | this.ws.on('connection', (ws, req) => { 54 | const connection = new Connection(ws, req, JsonEncoder) 55 | connection.sendOpenPacket() 56 | }) 57 | 58 | const client = helpers.startClient() 59 | client.on('message', function (payload) { 60 | assert.isTrue(msp.isOpenPacket(JSON.parse(payload))) 61 | done() 62 | }) 63 | }) 64 | 65 | test('send error when channel join packet is missing topic', (assert, done) => { 66 | assert.plan(1) 67 | 68 | this.ws = helpers.startWsServer() 69 | this.ws.on('connection', (ws, req) => { 70 | /* eslint no-new: "off" */ 71 | new Connection(ws, req, JsonEncoder) 72 | }) 73 | 74 | const client = helpers.startClient() 75 | client.on('message', function (payload) { 76 | const packet = JSON.parse(payload) 77 | done(() => { 78 | assert.deepEqual(packet, { t: msp.codes.JOIN_ERROR, d: { topic: 'unknown', message: 'Missing topic name' } }) 79 | }) 80 | }) 81 | 82 | client.on('open', function () { 83 | client.send(JSON.stringify({ t: msp.codes.JOIN })) 84 | }) 85 | }) 86 | 87 | test('send error when no matching channel found for a topic', (assert, done) => { 88 | assert.plan(1) 89 | 90 | this.ws = helpers.startWsServer() 91 | this.ws.on('connection', (ws, req) => { 92 | /* eslint no-new: "off" */ 93 | new Connection(ws, req, JsonEncoder) 94 | }) 95 | 96 | const client = helpers.startClient() 97 | client.on('message', function (payload) { 98 | const packet = JSON.parse(payload) 99 | done(() => { 100 | assert.deepEqual(packet, { 101 | t: msp.codes.JOIN_ERROR, 102 | d: { topic: 'chat', message: 'Topic cannot be handled by any channel' } 103 | }) 104 | }) 105 | }) 106 | 107 | client.on('open', function () { 108 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 109 | }) 110 | }) 111 | 112 | test('send JOIN_ACK when able to subscribe to a topic', (assert, done) => { 113 | assert.plan(1) 114 | 115 | this.ws = helpers.startWsServer() 116 | this.ws.on('connection', (ws, req) => { 117 | /* eslint no-new: "off" */ 118 | new Connection(ws, req, JsonEncoder) 119 | }) 120 | 121 | Manager.add('chat', function () {}) 122 | 123 | const client = helpers.startClient() 124 | client.on('message', function (payload) { 125 | const packet = JSON.parse(payload) 126 | done(() => { 127 | assert.deepEqual(packet, { t: msp.codes.JOIN_ACK, d: { topic: 'chat' } }) 128 | }) 129 | }) 130 | 131 | client.on('open', function () { 132 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 133 | }) 134 | }) 135 | 136 | test('save socket reference when joinTopic succeeds', (assert, done) => { 137 | assert.plan(3) 138 | let connection = null 139 | let connectedSocket = null 140 | 141 | this.ws = helpers.startWsServer() 142 | this.ws.on('connection', (ws, req) => { 143 | connection = new Connection(ws, req, JsonEncoder) 144 | }) 145 | 146 | Manager.add('chat', function ({ socket }) { 147 | connectedSocket = socket 148 | }) 149 | 150 | const client = helpers.startClient() 151 | 152 | client.on('message', function () { 153 | done(() => { 154 | assert.equal(Manager.channels.get('chat').subscriptions.size, 1) 155 | assert.deepEqual(Manager.channels.get('chat').subscriptions.get('chat'), new Set([connectedSocket])) 156 | assert.deepEqual(connection._subscriptions.get('chat'), connectedSocket) 157 | }) 158 | }) 159 | 160 | client.on('open', function () { 161 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 162 | }) 163 | }) 164 | 165 | test('joining same topic twice should result in error', (assert, done) => { 166 | assert.plan(1) 167 | let joinCallbackTriggerCount = 0 168 | 169 | this.ws = helpers.startWsServer() 170 | this.ws.on('connection', (ws, req) => { 171 | /* eslint no-new: "off" */ 172 | new Connection(ws, req, JsonEncoder) 173 | }) 174 | 175 | Manager.add('chat', function () { 176 | joinCallbackTriggerCount++ 177 | if (joinCallbackTriggerCount > 1) { 178 | done(new Error('Was not expecting this to be called')) 179 | } 180 | }) 181 | 182 | const client = helpers.startClient() 183 | 184 | client.on('message', function (payload) { 185 | const packet = JSON.parse(payload) 186 | if (packet.t === msp.codes.JOIN_ERROR) { 187 | done(() => { 188 | assert.deepEqual(packet, { 189 | t: msp.codes.JOIN_ERROR, 190 | d: { topic: 'chat', message: 'Cannot join the same topic twice' } 191 | }) 192 | }) 193 | } 194 | }) 195 | 196 | client.on('open', function () { 197 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 198 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 199 | }) 200 | }) 201 | 202 | test('handle join topic calls in correct sequence', (assert, done) => { 203 | assert.plan(2) 204 | 205 | const responsePackets = [] 206 | let connection = null 207 | 208 | this.ws = helpers.startWsServer() 209 | this.ws.on('connection', (ws, req) => { 210 | connection = new Connection(ws, req, JsonEncoder) 211 | }) 212 | 213 | Manager.add('chat', function () {}) 214 | 215 | const client = helpers.startClient() 216 | 217 | client.on('message', function (payload) { 218 | const packet = JSON.parse(payload) 219 | responsePackets.push(packet) 220 | 221 | if (responsePackets.length === 4) { 222 | done(() => { 223 | assert.deepEqual(responsePackets, [ 224 | { t: msp.codes.JOIN_ERROR, d: { message: 'Missing topic name', topic: 'unknown' } }, 225 | { t: msp.codes.JOIN_ERROR, d: { message: 'Topic cannot be handled by any channel', topic: 'foo' } }, 226 | { t: msp.codes.JOIN_ACK, d: { topic: 'chat' } }, 227 | { t: msp.codes.JOIN_ERROR, d: { message: 'Cannot join the same topic twice', topic: 'chat' } } 228 | ]) 229 | assert.deepEqual(connection._subscriptionsQueue, []) 230 | }) 231 | } 232 | }) 233 | 234 | client.on('open', function () { 235 | client.send(JSON.stringify({ t: msp.codes.JOIN })) 236 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'foo' } })) 237 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 238 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 239 | }) 240 | }) 241 | 242 | test('leave topic without topic name should return error', (assert, done) => { 243 | assert.plan(1) 244 | 245 | this.ws = helpers.startWsServer() 246 | this.ws.on('connection', (ws, req) => { 247 | /* eslint no-new: "off" */ 248 | new Connection(ws, req, JsonEncoder) 249 | }) 250 | 251 | Manager.add('chat', function () {}) 252 | 253 | const client = helpers.startClient() 254 | 255 | client.on('message', function (payload) { 256 | const packet = JSON.parse(payload) 257 | done(() => { 258 | assert.deepEqual(packet, { t: msp.codes.LEAVE_ERROR, d: { topic: 'unknown', message: 'Missing topic name' } }) 259 | }) 260 | }) 261 | 262 | client.on('open', function () { 263 | client.send(JSON.stringify({ t: msp.codes.LEAVE })) 264 | }) 265 | }) 266 | 267 | test('ACK leave topic', (assert, done) => { 268 | assert.plan(1) 269 | 270 | const responsePackets = [] 271 | 272 | this.ws = helpers.startWsServer() 273 | this.ws.on('connection', (ws, req) => { 274 | /* eslint no-new: "off" */ 275 | new Connection(ws, req, JsonEncoder) 276 | }) 277 | 278 | Manager.add('chat', function () {}) 279 | 280 | const client = helpers.startClient() 281 | 282 | client.on('message', function (payload) { 283 | const packet = JSON.parse(payload) 284 | responsePackets.push(packet) 285 | 286 | if (responsePackets.length === 2) { 287 | done(() => { 288 | assert.deepEqual(responsePackets, [ 289 | { t: msp.codes.JOIN_ACK, d: { topic: 'chat' } }, 290 | { t: msp.codes.LEAVE_ACK, d: { topic: 'chat' } } 291 | ]) 292 | }) 293 | } 294 | }) 295 | 296 | client.on('open', function () { 297 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 298 | client.send(JSON.stringify({ t: msp.codes.LEAVE, d: { topic: 'chat' } })) 299 | }) 300 | }) 301 | 302 | test('cleanup topic socket from channel and connection on leave', (assert, done) => { 303 | assert.plan(2) 304 | 305 | let eventsCount = 0 306 | let connection = null 307 | 308 | this.ws = helpers.startWsServer() 309 | this.ws.on('connection', (ws, req) => { 310 | connection = new Connection(ws, req, JsonEncoder) 311 | }) 312 | 313 | const channel = Manager.add('chat', function () {}) 314 | 315 | const client = helpers.startClient() 316 | 317 | client.on('message', function (payload) { 318 | eventsCount++ 319 | if (eventsCount === 2) { 320 | done(() => { 321 | assert.equal(connection._subscriptions.size, 0) 322 | assert.equal(channel.subscriptions.get('chat').size, 0) 323 | }) 324 | } 325 | }) 326 | 327 | client.on('open', function () { 328 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 329 | client.send(JSON.stringify({ t: msp.codes.LEAVE, d: { topic: 'chat' } })) 330 | }) 331 | }) 332 | 333 | test('one connection should not interfere with other connection', (assert, done) => { 334 | assert.plan(5) 335 | 336 | let eventsCount = 0 337 | const connections = {} 338 | 339 | this.ws = helpers.startWsServer() 340 | this.ws.on('connection', (ws, req) => { 341 | const connection = new Connection(ws, req, JsonEncoder) 342 | connections[req.url] = connection 343 | }) 344 | 345 | const channel = Manager.add('chat', function () {}) 346 | 347 | const client = helpers.startClient({ seq: 1 }) 348 | const client1 = helpers.startClient({ seq: 2 }) 349 | 350 | const messageHandler = function () { 351 | eventsCount++ 352 | if (eventsCount === 3) { 353 | setTimeout(() => { 354 | done(function () { 355 | assert.notEqual(connections['/?seq=1'].id, connections['/?seq=2'].id) 356 | assert.equal(connections['/?seq=1']._subscriptions.size, 0) 357 | assert.equal(connections['/?seq=2']._subscriptions.size, 1) 358 | assert.equal(channel.subscriptions.get('chat').size, 1) 359 | assert.equal(channel.subscriptions.get('chat').values().next().value.id, `chat#${connections['/?seq=2'].id}`) 360 | }) 361 | }) 362 | } 363 | } 364 | 365 | client.on('message', messageHandler) 366 | client1.on('message', messageHandler) 367 | 368 | client.on('open', function () { 369 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 370 | client.send(JSON.stringify({ t: msp.codes.LEAVE, d: { topic: 'chat' } })) 371 | }) 372 | 373 | client1.on('open', function () { 374 | client1.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 375 | }) 376 | }) 377 | 378 | test('kill all connection subscriptions on close', (assert, done) => { 379 | assert.plan(2) 380 | let connection = null 381 | 382 | this.ws = helpers.startWsServer() 383 | this.ws.on('connection', (ws, req) => { 384 | connection = new Connection(ws, req, JsonEncoder) 385 | }) 386 | 387 | const channel = Manager.add('chat', function () {}) 388 | const client = helpers.startClient() 389 | 390 | client.on('message', function () { 391 | client.close() 392 | setTimeout(() => { 393 | assert.equal(connection._subscriptions.size, 0) 394 | assert.equal(channel.subscriptions.get('chat').size, 0) 395 | done() 396 | }, 1000) 397 | }) 398 | 399 | client.on('open', function () { 400 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 401 | }) 402 | }) 403 | 404 | test('emit close on subscription when connection closes', (assert, done) => { 405 | assert.plan(3) 406 | let connection = null 407 | let closedCalled = false 408 | 409 | this.ws = helpers.startWsServer() 410 | this.ws.on('connection', (ws, req) => { 411 | connection = new Connection(ws, req, JsonEncoder) 412 | }) 413 | 414 | const channel = Manager.add('chat', function ({ socket }) { 415 | socket.on('close', function () { 416 | closedCalled = true 417 | }) 418 | }) 419 | const client = helpers.startClient() 420 | 421 | client.on('message', function () { 422 | client.close() 423 | setTimeout(() => { 424 | done(() => { 425 | assert.equal(connection._subscriptions.size, 0) 426 | assert.equal(channel.subscriptions.get('chat').size, 0) 427 | assert.isTrue(closedCalled) 428 | }) 429 | }, 1000) 430 | }) 431 | 432 | client.on('open', function () { 433 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 434 | }) 435 | }) 436 | 437 | test('ignore non existing topic leaves', (assert, done) => { 438 | assert.plan(1) 439 | 440 | this.ws = helpers.startWsServer() 441 | this.ws.on('connection', (ws, req) => { 442 | /* eslint no-new: "off" */ 443 | new Connection(ws, req, JsonEncoder) 444 | }) 445 | 446 | Manager.add('chat', function () {}) 447 | 448 | const client = helpers.startClient() 449 | 450 | client.on('message', function (payload) { 451 | const packet = JSON.parse(payload) 452 | assert.deepEqual(packet, { 453 | t: msp.codes.LEAVE_ACK, d: { topic: 'chat' } 454 | }) 455 | done() 456 | }) 457 | 458 | client.on('open', function () { 459 | client.send(JSON.stringify({ t: msp.codes.LEAVE, d: { topic: 'chat' } })) 460 | }) 461 | }) 462 | 463 | test('discard topic join when channel middleware throws exception', (assert, done) => { 464 | assert.plan(3) 465 | 466 | let connection = null 467 | 468 | this.ws = helpers.startWsServer() 469 | this.ws.on('connection', (ws, req) => { 470 | connection = new Connection(ws, req, JsonEncoder) 471 | }) 472 | 473 | const channel = Manager 474 | .add('chat', function () {}) 475 | .middleware(async () => { 476 | throw new Error('Unauthorized') 477 | }) 478 | 479 | const client = helpers.startClient() 480 | 481 | client.on('message', function (payload) { 482 | const packet = JSON.parse(payload) 483 | done(function () { 484 | assert.deepEqual(packet, { 485 | t: msp.codes.JOIN_ERROR, d: { topic: 'chat', message: 'Unauthorized' } 486 | }) 487 | assert.equal(connection._subscriptions.size, 0) 488 | assert.equal(channel.subscriptions.size, 0) 489 | }) 490 | }) 491 | 492 | client.on('open', function () { 493 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 494 | }) 495 | }) 496 | 497 | test('send event from server to client', (assert, done) => { 498 | assert.plan(1) 499 | 500 | this.ws = helpers.startWsServer() 501 | this.ws.on('connection', (ws, req) => { 502 | new Connection(ws, req, JsonEncoder) 503 | }) 504 | 505 | Manager.add('chat', function ({ socket }) { 506 | socket.emit('greeting', 'hello') 507 | }) 508 | 509 | const client = helpers.startClient() 510 | 511 | client.on('message', function (payload) { 512 | const packet = JSON.parse(payload) 513 | if (packet.t === msp.codes.EVENT) { 514 | done(function () { 515 | assert.deepEqual(packet.d, { topic: 'chat', event: 'greeting', data: 'hello' }) 516 | }) 517 | } 518 | }) 519 | 520 | client.on('open', function () { 521 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 522 | }) 523 | }) 524 | 525 | test('pass encoder error to ack method', (assert, done) => { 526 | assert.plan(1) 527 | 528 | this.ws = helpers.startWsServer() 529 | this.ws.on('connection', (ws, req) => { 530 | const msgpackEncoder = Object.assign({}, JsonEncoder, { 531 | encode (payload, cb) { 532 | cb(new Error('Cannot encode')) 533 | } 534 | }) 535 | 536 | const connection = new Connection(ws, req, msgpackEncoder) 537 | connection.sendPacket(1, {}, function (error) { 538 | assert.equal(error.message, 'Cannot encode') 539 | done() 540 | }) 541 | }) 542 | 543 | helpers.startClient() 544 | }) 545 | 546 | test('pass error to emit ack method when underlying connection is closed', (assert, done) => { 547 | assert.plan(1) 548 | 549 | this.ws = helpers.startWsServer() 550 | 551 | this.ws.on('connection', (ws, req) => { 552 | const connection = new Connection(ws, req, JsonEncoder) 553 | connection.close() 554 | 555 | connection.sendPacket({ d: 1 }, {}, (error) => { 556 | setTimeout(() => { 557 | done(() => { 558 | assert.equal(error.message, 'connection is closed') 559 | }) 560 | }, 1000) 561 | }) 562 | }) 563 | 564 | helpers.startClient() 565 | }) 566 | 567 | test('return hard error when sending message without a topic', (assert, done) => { 568 | assert.plan(1) 569 | 570 | this.ws = helpers.startWsServer() 571 | 572 | this.ws.on('connection', (ws, req) => { 573 | const connection = new Connection(ws, req, JsonEncoder) 574 | const fn = () => connection.sendEvent() 575 | done(() => { 576 | assert.throw(fn, 'Cannot send event without a topic') 577 | }) 578 | }) 579 | 580 | helpers.startClient() 581 | }) 582 | 583 | test('return hard error when topic has no active subscriptions on a connection', (assert, done) => { 584 | assert.plan(1) 585 | 586 | this.ws = helpers.startWsServer() 587 | 588 | this.ws.on('connection', (ws, req) => { 589 | const connection = new Connection(ws, req, JsonEncoder) 590 | const fn = () => connection.sendEvent('chat') 591 | done(() => { 592 | assert.throw(fn, 'Topic chat doesn\'t have any active subscriptions') 593 | }) 594 | }) 595 | 596 | helpers.startClient() 597 | }) 598 | 599 | test('drop packet when client doesn\'t define the packet type', (assert, done) => { 600 | this.ws = helpers.startWsServer() 601 | 602 | this.ws.on('connection', (ws, req) => { 603 | const connection = new Connection(ws, req, JsonEncoder) 604 | connection._handleMessage = function () { 605 | throw new Error('Never expected to be executed') 606 | } 607 | }) 608 | 609 | const client = helpers.startClient() 610 | client.on('open', () => { 611 | client.send(JSON.stringify({ hello: 'world' })) 612 | setTimeout(() => { 613 | done() 614 | }) 615 | }) 616 | }) 617 | 618 | test('drop packet when unable to decode packet', (assert, done) => { 619 | this.ws = helpers.startWsServer() 620 | 621 | this.ws.on('connection', (ws, req) => { 622 | const connection = new Connection(ws, req, JsonEncoder) 623 | connection._handleMessage = function () { 624 | throw new Error('Never expected to be executed') 625 | } 626 | }) 627 | 628 | const client = helpers.startClient() 629 | client.on('open', () => { 630 | client.send('hello') 631 | setTimeout(() => { 632 | done() 633 | }) 634 | }) 635 | }) 636 | 637 | test('remove subscription reference from connection when subscription closes from server', (assert, done) => { 638 | assert.plan(2) 639 | let connection = null 640 | 641 | this.ws = helpers.startWsServer() 642 | this.ws.on('connection', (ws, req) => { 643 | connection = new Connection(ws, req, JsonEncoder) 644 | }) 645 | 646 | const channel = Manager.add('chat', function ({ socket }) { 647 | socket.close() 648 | }) 649 | 650 | const client = helpers.startClient() 651 | 652 | client.on('message', function (payload) { 653 | const packet = JSON.parse(payload) 654 | if (packet.t === msp.codes.LEAVE) { 655 | setTimeout(() => { 656 | assert.equal(connection._subscriptions.size, 0) 657 | assert.equal(channel.subscriptions.get('chat').size, 0) 658 | done() 659 | }, 1000) 660 | } 661 | }) 662 | 663 | client.on('open', function () { 664 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 665 | }) 666 | }) 667 | 668 | test('pass client message to the subscription instance', (assert, done) => { 669 | assert.plan(1) 670 | 671 | this.ws = helpers.startWsServer() 672 | this.ws.on('connection', (ws, req) => { 673 | new Connection(ws, req, JsonEncoder) 674 | }) 675 | 676 | Manager.add('chat', function ({ socket }) { 677 | socket.on('greeting', (greeting) => { 678 | done(() => { 679 | assert.equal(greeting, 'hello') 680 | }) 681 | }) 682 | }) 683 | 684 | const client = helpers.startClient() 685 | 686 | client.on('message', function (payload) { 687 | const packet = JSON.parse(payload) 688 | if (packet.t === msp.codes.JOIN_ACK) { 689 | client.send(JSON.stringify({ t: msp.codes.EVENT, d: { topic: 'chat', event: 'greeting', data: 'hello' } })) 690 | } 691 | }) 692 | 693 | client.on('open', function () { 694 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 695 | }) 696 | }) 697 | 698 | test('drop event packet when there is no data', (assert, done) => { 699 | this.ws = helpers.startWsServer() 700 | this.ws.on('connection', (ws, req) => { 701 | new Connection(ws, req, JsonEncoder) 702 | }) 703 | 704 | Manager.add('chat', function ({ socket }) { 705 | socket.on('greeting', (greeting) => { 706 | throw new Error('Never expected to be called') 707 | }) 708 | }) 709 | 710 | const client = helpers.startClient() 711 | 712 | client.on('message', function (payload) { 713 | const packet = JSON.parse(payload) 714 | if (packet.t === msp.codes.JOIN_ACK) { 715 | client.send(JSON.stringify({ t: msp.codes.EVENT })) 716 | setTimeout(() => { 717 | done() 718 | }, 500) 719 | } 720 | }) 721 | 722 | client.on('open', function () { 723 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 724 | }) 725 | }) 726 | 727 | test('drop event packet when there are no subscriptions for the topic', (assert, done) => { 728 | this.ws = helpers.startWsServer() 729 | this.ws.on('connection', (ws, req) => { 730 | new Connection(ws, req, JsonEncoder) 731 | }) 732 | 733 | const client = helpers.startClient() 734 | 735 | client.on('open', function () { 736 | client.send(JSON.stringify({ t: msp.codes.EVENT, d: { topic: 'chat', event: 'greeting', data: 'hello' } })) 737 | setTimeout(() => { 738 | done() 739 | }, 500) 740 | }) 741 | }) 742 | 743 | test('reset pingElapsed on each message frame', (assert, done) => { 744 | let connection = null 745 | 746 | this.ws = helpers.startWsServer() 747 | 748 | this.ws.on('connection', (ws, req) => { 749 | connection = new Connection(ws, req, JsonEncoder) 750 | connection.pingElapsed = 1 751 | }) 752 | 753 | const client = helpers.startClient() 754 | 755 | client.on('open', function () { 756 | client.send(JSON.stringify({ t: msp.codes.EVENT, d: { topic: 'chat', event: 'greeting', data: 'hello' } })) 757 | setTimeout(() => { 758 | done(() => { 759 | assert.equal(connection.pingElapsed, 0) 760 | }) 761 | }, 200) 762 | }) 763 | }) 764 | 765 | test('remove subscription when error occurs in connection', (assert, done) => { 766 | assert.plan(2) 767 | 768 | let connection = null 769 | 770 | this.ws = helpers.startWsServer() 771 | this.ws.on('connection', (ws, req) => { 772 | connection = new Connection(ws, req, JsonEncoder) 773 | connection.pingElapsed = 1 774 | }) 775 | 776 | Manager.add('chat', function () { 777 | connection.ws._socket.destroy(new Error('self destroyed')) 778 | assert.equal(connection._subscriptions.size, 1) 779 | }) 780 | 781 | const client = helpers.startClient() 782 | 783 | client.on('close', () => { 784 | assert.equal(connection._subscriptions.size, 0) 785 | done() 786 | }) 787 | 788 | client.on('open', () => { 789 | client.send(JSON.stringify({ t: msp.codes.JOIN, d: { topic: 'chat' } })) 790 | }) 791 | }) 792 | 793 | test('on ping send pong', (assert, done) => { 794 | assert.plan(1) 795 | 796 | this.ws = helpers.startWsServer() 797 | this.ws.on('connection', (ws, req) => { 798 | new Connection(ws, req, JsonEncoder) 799 | }) 800 | 801 | const client = helpers.startClient() 802 | 803 | client.on('message', (payload) => { 804 | assert.deepEqual(JSON.parse(payload), { t: msp.codes.PONG }) 805 | done() 806 | }) 807 | 808 | client.on('open', () => { 809 | client.send(JSON.stringify({ t: msp.codes.PING })) 810 | }) 811 | }) 812 | 813 | test('write payload to the socket', (assert, done) => { 814 | assert.plan(1) 815 | 816 | this.ws = helpers.startWsServer() 817 | this.ws.on('connection', (ws, req) => { 818 | const connection = new Connection(ws, req, JsonEncoder) 819 | connection.write('hello world') 820 | }) 821 | 822 | const client = helpers.startClient() 823 | 824 | client.on('message', function (payload) { 825 | assert.equal(payload, 'hello world') 826 | done() 827 | }) 828 | }) 829 | 830 | test('return error when trying to write but connection is closed', (assert, done) => { 831 | assert.plan(1) 832 | 833 | this.ws = helpers.startWsServer() 834 | this.ws.on('connection', (ws, req) => { 835 | const connection = new Connection(ws, req, JsonEncoder) 836 | connection.close() 837 | setTimeout(() => { 838 | connection.write('hello world', {}, ({ message }) => { 839 | assert.equal(message, 'connection is closed') 840 | done() 841 | }) 842 | }) 843 | }) 844 | 845 | const client = helpers.startClient() 846 | client.on('message', function (payload) { 847 | assert.throw('Not expecting to the called') 848 | }) 849 | }) 850 | 851 | test('drop invalid packets', (assert, done) => { 852 | assert.plan(1) 853 | 854 | this.ws = helpers.startWsServer() 855 | this.ws.on('connection', (ws, req) => { 856 | const connection = new Connection(ws, req, JsonEncoder) 857 | connection._notifyPacketDropped = function (handle, message) { 858 | assert.equal(message, 'invalid packet %j') 859 | done() 860 | } 861 | }) 862 | 863 | const client = helpers.startClient() 864 | client.on('open', function (payload) { 865 | client.send(JSON.stringify({ t: 99 })) 866 | }) 867 | }) 868 | }) 869 | -------------------------------------------------------------------------------- /test/unit/json-encoder.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket-client 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const test = require('japa') 13 | const JsonEncoder = require('../../src/JsonEncoder/index.js') 14 | 15 | test.group('JsonEncoder', (group) => { 16 | test('encode value', (assert, done) => { 17 | assert.plan(1) 18 | 19 | JsonEncoder.encode({ name: 'virk' }, (error, payload) => { 20 | if (error) { 21 | done(error) 22 | return 23 | } 24 | 25 | assert.equal(payload, JSON.stringify({ name: 'virk' })) 26 | done() 27 | }) 28 | }) 29 | 30 | test('pass encoding error to callback', (assert, done) => { 31 | assert.plan(1) 32 | 33 | const obj = {} 34 | Object.defineProperty(obj, 'name', { 35 | enumerable: true, 36 | get () { 37 | throw new Error('bad') 38 | } 39 | }) 40 | 41 | JsonEncoder.encode(obj, (error, payload) => { 42 | assert.equal(error.message, 'bad') 43 | done() 44 | }) 45 | }) 46 | 47 | test('decode json string', (assert, done) => { 48 | assert.plan(1) 49 | JsonEncoder.decode(JSON.stringify({ name: 'virk' }), (error, payload) => { 50 | if (error) { 51 | done(error) 52 | return 53 | } 54 | 55 | assert.deepEqual(payload, { name: 'virk' }) 56 | done() 57 | }) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /test/unit/socket.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const test = require('japa') 13 | const Socket = require('../../src/Socket') 14 | const helpers = require('../helpers') 15 | 16 | const FakeConnection = helpers.getFakeConnection() 17 | const FakeChannel = helpers.getFakeChannel() 18 | 19 | test.group('Socket', () => { 20 | test('inherit id from connection id', (assert) => { 21 | const socket = new Socket('chat', new FakeConnection(123)) 22 | assert.equal(socket.id, 'chat#123') 23 | }) 24 | 25 | test('socket must have ready only topic name', (assert) => { 26 | const socket = new Socket('chat', new FakeConnection()) 27 | assert.equal(socket.topic, 'chat') 28 | const fn = () => (socket.topic = 'foo') 29 | assert.throw(fn, 'Cannot set property topic of # which has only a getter') 30 | }) 31 | 32 | test('associate channel when associateChannel method is called', (assert) => { 33 | const socket = new Socket('chat', new FakeConnection()) 34 | socket.associateChannel(new FakeChannel('chat')) 35 | assert.instanceOf(socket.channel, FakeChannel) 36 | }) 37 | 38 | test('on close emit the close event', (assert, done) => { 39 | const socket = new Socket('chat', new FakeConnection()) 40 | socket.associateChannel(new FakeChannel('chat')) 41 | 42 | socket.on('close', function () { 43 | done() 44 | }) 45 | socket.close() 46 | }) 47 | 48 | test('on close remove all event listeners', async (assert) => { 49 | const socket = new Socket('chat', new FakeConnection()) 50 | const channel = new FakeChannel('chat') 51 | 52 | socket.associateChannel(channel) 53 | socket.on('close', function () {}) 54 | assert.equal(socket.emitter.listenerCount(), 1) 55 | 56 | await socket.close() 57 | assert.equal(socket.emitter.listenerCount(), 0) 58 | }) 59 | 60 | test('on close remove all event listeners even when close event throws exception', async (assert) => { 61 | const socket = new Socket('chat', new FakeConnection()) 62 | const channel = new FakeChannel('chat') 63 | 64 | socket.associateChannel(channel) 65 | 66 | socket.on('close', function () { 67 | throw new Error('foo') 68 | }) 69 | assert.equal(socket.emitter.listenerCount(), 1) 70 | 71 | await socket.close() 72 | assert.equal(socket.emitter.listenerCount(), 0) 73 | }) 74 | }) 75 | 76 | test.group('Socket emitting', () => { 77 | test('emit event to itself', (assert, done) => { 78 | assert.plan(3) 79 | 80 | const connection = new FakeConnection() 81 | const socket = new Socket('chat', connection) 82 | const channel = new FakeChannel('chat') 83 | 84 | connection.sendEvent = function (topic, event, data) { 85 | assert.equal(topic, 'chat') 86 | assert.equal(event, 'hello') 87 | assert.equal(data, 'world') 88 | done() 89 | } 90 | 91 | socket.associateChannel(channel) 92 | socket.emit('hello', 'world') 93 | }) 94 | 95 | test('broadcast message to entire channel except itself by encoding the message', (assert, done) => { 96 | assert.plan(3) 97 | 98 | const socket = new Socket('chat', new FakeConnection()) 99 | const socket1 = new Socket('chat', new FakeConnection()) 100 | 101 | const channel = new FakeChannel('chat') 102 | channel.broadcastPayload = function (topic, payload, filterIds) { 103 | done(() => { 104 | assert.equal(topic, 'chat') 105 | assert.deepEqual(payload, { topic: 'chat', event: 'hello', data: 'world' }) 106 | assert.deepEqual(filterIds, [socket.id]) 107 | }) 108 | } 109 | 110 | socket.associateChannel(channel) 111 | socket1.associateChannel(channel) 112 | 113 | socket.broadcast('hello', 'world') 114 | }) 115 | 116 | test('broadcast message to entire channel by encoding the message', (assert, done) => { 117 | assert.plan(3) 118 | 119 | const socket = new Socket('chat', new FakeConnection()) 120 | const socket1 = new Socket('chat', new FakeConnection()) 121 | 122 | const channel = new FakeChannel('chat') 123 | channel.broadcastPayload = function (topic, payload, filterIds) { 124 | done(() => { 125 | assert.equal(topic, 'chat') 126 | assert.deepEqual(payload, { topic: 'chat', event: 'hello', data: 'world' }) 127 | assert.deepEqual(filterIds, []) 128 | }) 129 | } 130 | 131 | socket.associateChannel(channel) 132 | socket1.associateChannel(channel) 133 | 134 | socket.broadcastToAll('hello', 'world') 135 | }) 136 | 137 | test('broadcast message to selected socket ids', (assert, done) => { 138 | assert.plan(4) 139 | 140 | const socket = new Socket('chat', new FakeConnection()) 141 | const socket1 = new Socket('chat', new FakeConnection()) 142 | const socket2 = new Socket('chat', new FakeConnection()) 143 | 144 | const channel = new FakeChannel('chat') 145 | channel.broadcastPayload = function (topic, payload, filterIds, inverse) { 146 | done(() => { 147 | assert.equal(topic, 'chat') 148 | assert.deepEqual(payload, { topic: 'chat', event: 'hello', data: 'world' }) 149 | assert.deepEqual(filterIds, [socket1.id, socket2.id]) 150 | assert.isTrue(inverse) 151 | }) 152 | } 153 | 154 | socket.associateChannel(channel) 155 | socket1.associateChannel(channel) 156 | socket2.associateChannel(channel) 157 | 158 | socket.emitTo('hello', 'world', [socket1.id, socket2.id]) 159 | }) 160 | 161 | test('throw exception when ids array is not passed to emitTo', (assert) => { 162 | const socket = new Socket('chat', new FakeConnection()) 163 | const channel = new FakeChannel('chat') 164 | 165 | socket.associateChannel(channel) 166 | 167 | const fn = () => socket.emitTo('hello', 'world') 168 | assert.throw(fn, /E_INVALID_PARAMETER: emitTo expects 3rd parameter to be an array of socket ids/) 169 | }) 170 | }) 171 | -------------------------------------------------------------------------------- /test/unit/ws.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-websocket 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const test = require('japa') 13 | const { Config } = require('@adonisjs/sink') 14 | const Ws = require('../../src/Ws') 15 | const middleware = require('../../src/Middleware') 16 | 17 | const helpers = require('../helpers') 18 | 19 | test.group('Ws', (group) => { 20 | group.afterEach(() => { 21 | middleware._middleware.global = [] 22 | middleware._middleware.named = {} 23 | 24 | if (this.ws) { 25 | this.ws.close() 26 | } 27 | 28 | if (this.httpServer) { 29 | this.httpServer.close() 30 | } 31 | }) 32 | 33 | test('start server to accept new connections', (assert, done) => { 34 | this.ws = new Ws(new Config()) 35 | this.httpServer = helpers.startHttpServer() 36 | this.ws.listen(this.httpServer) 37 | 38 | const client = helpers.startClient({}, '/adonis-ws') 39 | client.on('open', () => done()) 40 | }) 41 | 42 | test('bind function to verify handshake', (assert, done) => { 43 | assert.plan(1) 44 | this.ws = new Ws(new Config()) 45 | this.httpServer = helpers.startHttpServer() 46 | 47 | this.ws.onHandshake(async (info) => { 48 | assert.equal(info.req.url, '/adonis-ws') 49 | }) 50 | 51 | this.ws.listen(this.httpServer) 52 | 53 | const client = helpers.startClient({}, '/adonis-ws') 54 | client.on('open', () => done()) 55 | }) 56 | 57 | test('cancel handshake when handshake fn returns error', (assert, done) => { 58 | assert.plan(1) 59 | this.ws = new Ws(new Config()) 60 | this.httpServer = helpers.startHttpServer() 61 | 62 | this.ws.onHandshake(async (info) => { 63 | throw new Error('Cannot accept') 64 | }) 65 | 66 | this.ws.listen(this.httpServer) 67 | 68 | const client = helpers.startClient({}, '/adonis-ws') 69 | client.on('error', (error) => { 70 | done(() => { 71 | assert.equal(error.message, 'Unexpected server response: 401') 72 | }) 73 | }) 74 | }) 75 | 76 | test('return error when handshake fn is not a function', (assert) => { 77 | this.ws = new Ws(new Config()) 78 | const fn = () => this.ws.onHandshake({}) 79 | assert.throw(fn, 'E_INVALID_PARAMETER: Ws.onHandshake accepts a function instead received object') 80 | }) 81 | 82 | test('handshake fn can be sync function', (assert, done) => { 83 | assert.plan(1) 84 | this.ws = new Ws(new Config()) 85 | this.httpServer = helpers.startHttpServer() 86 | 87 | this.ws.onHandshake((info) => { 88 | throw new Error('Cannot accept') 89 | }) 90 | 91 | this.ws.listen(this.httpServer) 92 | 93 | const client = helpers.startClient({}, '/adonis-ws') 94 | client.on('error', (error) => { 95 | done(() => { 96 | assert.equal(error.message, 'Unexpected server response: 401') 97 | }) 98 | }) 99 | }) 100 | 101 | test('remove connection from the set when it closes', (assert, done) => { 102 | assert.plan(1) 103 | this.ws = new Ws(new Config()) 104 | this.httpServer = helpers.startHttpServer() 105 | this.ws.listen(this.httpServer) 106 | 107 | const client = helpers.startClient({}, '/adonis-ws') 108 | client.on('open', () => { 109 | this.ws._connections.forEach((connection) => { 110 | connection.close() 111 | }) 112 | setTimeout(() => { 113 | assert.equal(this.ws._connections.size, 0) 114 | done() 115 | }, 200) 116 | }) 117 | }) 118 | 119 | test('remove connection from the set when an error occurs in underlying connection', (assert, done) => { 120 | assert.plan(1) 121 | this.ws = new Ws(new Config()) 122 | this.httpServer = helpers.startHttpServer() 123 | this.ws.listen(this.httpServer) 124 | 125 | const client = helpers.startClient({}, '/adonis-ws') 126 | client.on('open', () => { 127 | this.ws._connections.forEach((connection) => { 128 | connection.ws._socket.destroy(new Error('self destroyed')) 129 | }) 130 | setTimeout(() => { 131 | assert.equal(this.ws._connections.size, 0) 132 | done() 133 | }, 200) 134 | }) 135 | }) 136 | 137 | test('register global middleware', (assert) => { 138 | this.ws = new Ws(new Config()) 139 | this.ws.registerGlobal(['Adonis/Middleware/AuthInit']) 140 | 141 | assert.deepEqual(middleware._middleware.global, [ 142 | { 143 | namespace: 'Adonis/Middleware/AuthInit.wsHandle', 144 | params: [] 145 | } 146 | ]) 147 | }) 148 | 149 | test('register named middleware', (assert) => { 150 | this.ws = new Ws(new Config()) 151 | this.ws.registerNamed({ 152 | auth: 'Adonis/Middleware/Auth' 153 | }) 154 | 155 | assert.deepEqual(middleware._middleware.named, { 156 | auth: { 157 | namespace: 'Adonis/Middleware/Auth.wsHandle', 158 | params: [] 159 | } 160 | }) 161 | }) 162 | 163 | test('work fine with slash in the end', (assert, done) => { 164 | this.ws = new Ws(new Config()) 165 | this.httpServer = helpers.startHttpServer() 166 | this.ws.listen(this.httpServer) 167 | 168 | const client = helpers.startClient({}, '/adonis-ws/') 169 | client.on('open', () => done()) 170 | }) 171 | }) 172 | --------------------------------------------------------------------------------