├── .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 |
--------------------------------------------------------------------------------