Repository: hapijs/nes Branch: master Commit: 790a2e3b7bdc Files: 21 Total size: 318.7 KB Directory structure: gitextract_wfcena34/ ├── .github/ │ └── workflows/ │ └── ci-plugin.yml ├── .gitignore ├── API.md ├── LICENSE.md ├── PROTOCOL.md ├── README.md ├── lib/ │ ├── client.d.ts │ ├── client.js │ ├── index.d.ts │ ├── index.js │ ├── listener.js │ └── socket.js ├── package.json └── test/ ├── auth.js ├── client.js ├── esm.js ├── index.js ├── listener.js ├── socket.js └── types/ ├── client.ts └── server.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci-plugin.yml ================================================ name: ci on: push: branches: - master - next pull_request: workflow_dispatch: jobs: test: uses: hapijs/.github/.github/workflows/ci-plugin.yml@min-node-18-hapi-21 ================================================ FILE: .gitignore ================================================ **/node_modules **/package-lock.json coverage.* **/.DS_Store **/._* **/*.pem **/.vs **/.vscode **/.idea ================================================ FILE: API.md ================================================ ## Introduction **nes** adds native WebSocket support to [**hapi**](https://github.com/hapijs/hapi)-based application servers. Instead of treating the WebSocket connections as a separate platform with its own security and application context, **nes** builds on top of the existing **hapi** architecture to provide a flexible and organic extension. Protocol version: 2.4.x (different from module version) ## Protocol The **nes** protocol is described in the [Protocol documentation](https://github.com/hapijs/nes/blob/master/PROTOCOL.md). ## Examples ### Route invocation #### Server ```js const Hapi = require('@hapi/hapi'); const Nes = require('@hapi/nes'); const server = new Hapi.Server(); const start = async () => { await server.register(Nes); server.route({ method: 'GET', path: '/h', config: { id: 'hello', handler: (request, h) => { return 'world!'; } } }); await server.start(); }; start(); ``` #### Client ```js const Nes = require('@hapi/nes'); var client = new Nes.Client('ws://localhost'); const start = async () => { await client.connect(); const payload = await client.request('hello'); // Can also request '/h' // payload -> 'world!' }; start(); ``` ### Subscriptions #### Server ```js const Hapi = require('@hapi/hapi'); const Nes = require('@hapi/nes'); const server = new Hapi.Server(); const start = async () => { await server.register(Nes); server.subscription('/item/{id}'); await server.start(); server.publish('/item/5', { id: 5, status: 'complete' }); server.publish('/item/6', { id: 6, status: 'initial' }); }; start(); ``` #### Client ```js const Nes = require('@hapi/nes'); const client = new Nes.Client('ws://localhost'); const start = async () => { await client.connect(); const handler = (update, flags) => { // update -> { id: 5, status: 'complete' } // Second publish is not received (doesn't match) }; client.subscribe('/item/5', handler); }; start(); ``` ### Broadcast #### Server ```js const Hapi = require('@hapi/hapi'); const Nes = require('@hapi/nes'); const server = new Hapi.Server(); const start = async () => { await server.register(Nes); await server.start(); server.broadcast('welcome!'); }; start(); ``` #### Client ```js const Nes = require('@hapi/nes'); const client = new Nes.Client('ws://localhost'); const start = async () => { await client.connect(); client.onUpdate = (update) => { // update -> 'welcome!' }; }; start(); ``` ### Route authentication #### Server ```js const Hapi = require('@hapi/hapi'); const Basic = require('@hapi/basic'); const Bcrypt = require('bcrypt'); const Nes = require('@hapi/nes'); const server = new Hapi.Server(); const start = async () => { await server.register([Basic, Nes]); // Set up HTTP Basic authentication const users = { john: { username: 'john', password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' name: 'John Doe', id: '2133d32a' } }; const validate = async (request, username, password) => { const user = users[username]; if (!user) { return { isValid: false }; } const isValid = await Bcrypt.compare(password, user.password); const credentials = { id: user.id, name: user.name }; return { isValid, credentials }; }; server.auth.strategy('simple', 'basic', { validate }); // Configure route with authentication server.route({ method: 'GET', path: '/h', config: { id: 'hello', handler: (request, h) => { return `Hello ${request.auth.credentials.name}`; } } }); await server.start(); }; start(); ``` #### Client ```js const Nes = require('@hapi/nes'); const client = new Nes.Client('ws://localhost'); const start = async () => { await client.connect({ auth: { headers: { authorization: 'Basic am9objpzZWNyZXQ=' } } }); const payload = await client.request('hello') // Can also request '/h' // payload -> 'Hello John Doe' }; start(); ``` ### Subscription filter #### Server ```js const Hapi = require('@hapi/hapi'); const Basic = require('@hapi/basic'); const Bcrypt = require('bcrypt'); const Nes = require('@hapi/nes'); const server = new Hapi.Server(); const start = async () => { await server.register([Basic, Nes]); // Set up HTTP Basic authentication const users = { john: { username: 'john', password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret' name: 'John Doe', id: '2133d32a' } }; const validate = async (request, username, password) => { const user = users[username]; if (!user) { return { isValid: false }; } const isValid = await Bcrypt.compare(password, user.password); const credentials = { id: user.id, name: user.name }; return { isValid, credentials }; }; server.auth.strategy('simple', 'basic', 'required', { validate }); // Set up subscription server.subscription('/items', { filter: (path, message, options) => { return (message.updater !== options.credentials.username); } }); await server.start(); server.publish('/items', { id: 5, status: 'complete', updater: 'john' }); server.publish('/items', { id: 6, status: 'initial', updater: 'steve' }); }; start(); ``` #### Client ```js const Nes = require('@hapi/nes'); const client = new Nes.Client('ws://localhost'); // Authenticate as 'john' const start = async () => { await client.connect({ auth: { headers: { authorization: 'Basic am9objpzZWNyZXQ=' } } }); const handler = (update, flags) => { // First publish is not received (filtered due to updater key) // update -> { id: 6, status: 'initial', updater: 'steve' } }; client.subscribe('/items', handler); }; start(); ``` ### Browser Client When you `require('@hapi/nes')` it loads the full module and adds a lot of extra code that is not needed for the browser. The browser will only need the **nes** client. If you are using CommonJS you can load the client with `require('@hapi/nes/lib/client')`. ## Registration The **nes** plugin uses the standard **hapi** registration process using the `server.register()` method. The plugin accepts the following optional registration options: - `onConnection` - a function with the signature `function(socket)` invoked for each incoming client connection where: - `socket` - the [`Socket`](#socket) object of the incoming connection. - `onDisconnection` - a function with the signature `function(socket)` invoked for each incoming client connection on disconnect where: - `socket` - the [`Socket`](#socket) object of the connection. - `onMessage` - a function with the signature `async function(socket, message)` used to receive custom client messages (when the client calls [`client.message()`](#clientmessagedata)) where: - `socket` - the [`Socket`](#socket) object of the message source. - `message` - the message sent by the client. - the function may return a response to the client. - `auth` - optional plugin authentication options with the following supported values: - `false` - no client authentication supported. - an object with the following optional keys: - `type` - the type of authentication flow supported by the server. Each type has a very different security profile. The following types are supported: - `'direct'` - the plugin configures an internal authentication endpoint which is only called internally by the plugin when the client provides its authentication credentials (or by passing an `auth` option to [`client.connect()`](#await-clientconnectoptions)). The endpoint returns a copy of the credentials object (along with any artifacts) to the plugin which is then used for all subsequent client requests and subscriptions. This type requires exposing the underlying credentials to the application. Note that if the authentication scheme uses the HTTP request method (e.g. [hawk](https://github.com/hueniverse/hawk) or [oz](https://github.com/hueniverse/oz)) you need to use `'auth'` as the value (and not `'GET'`). This is the default value. - `'cookie'` - the plugin configures a public authentication endpoint which must be called by the client application manually before it calls [`client.connect()`](#await-clientconnectoptions). When the endpoint is called with valid credentials, it sets a cookie with the provided `name` which the browser then transmits back to the server when the WebSocket connection is made. This type removes the need to expose the authentication credentials to the JavaScript layer but requires an additional round trip before establishing a client connection. - `'token'` - the plugin configures a public authentication endpoint which must be called by the client application manually before it calls [`client.connect()`](#await-clientconnectoptions). When the endpoint is called with valid credentials, it returns an encrypted authentication token which the client can use to authenticate the connection by passing an `auth` option to [`client.connect()`](#await-clientconnectoptions) with the token. This type is useful when the client-side application needs to manage its credentials differently than relying on cookies (e.g. non-browser clients). - `endpoint` - the HTTP path of the authentication endpoint. Note that even though the `'direct'` type does not exposes the endpoint, it is still created internally and registered using the provided path. Change it only if the default path creates a conflict. Defaults to `'/nes/auth'`. - `id` - the authentication endpoint identifier. Change it only if the default id creates a conflict. Defaults to `nes.auth`. - `route` - the **hapi** route `config.auth` settings. The authentication endpoint must be configured with at least one authentication strategy which the client is going to use to authenticate. The `route` value must be set to a valid value supported by the **hapi** route `auth` configuration. Defaults to the default authentication strategy if one is present, otherwise no authentication will be possible (clients will fail to authenticate). - `password` - the password used by the [**iron**](https://github.com/hueniverse/iron) module to encrypt the cookie or token values. If no password is provided, one is automatically generated. However, the password will change every time the process is restarted (as well as generate different results on a distributed system). It is recommended that a password is manually set and managed by the application. - `iron` - the settings used by the [**iron**](https://github.com/hueniverse/iron) module. Defaults to the **iron** defaults. - `cookie` - the cookie name when using type `'cookie'`. Defaults to `'nes'`. - `isSecure` - the cookie secure flag when using type `'cookie'`. Defaults to `true`. - `isHttpOnly` - the cookie HTTP only flag when using type `'cookie'`. Defaults to `true`. - `path` - the cookie path when using type `'cookie'`. Defaults to `'/'`. - `domain` - the cookie domain when using type `'cookie'`. Defaults to no domain. - `ttl` - the cookie expiration milliseconds when using type `'cookie'`. Defaults to current session only. - `index` - if `true`, authenticated socket with `user` property in `credentials` are mapped for usage in [`server.broadcast()`](#serverbroadcastmessage-options) calls. Defaults to `false`. - `timeout` - number of milliseconds after which a new connection is disconnected if authentication is required but the connection has not yet sent a hello message. No timeout if set to `false`. Defaults to `5000` (5 seconds). - `maxConnectionsPerUser` - if specified, limits authenticated users to a maximum number of client connections. Requires the `index` option enabled. Defaults to `false`. - `minAuthVerifyInterval` - if specified, waits at least the specificed number of milliseconds between calls to [`await server.auth.verify()`](https://hapijs.com/api#-await-serverauthverifyrequest) to check if credentials are still valid. Cannot be shorter than `heartbeat.interval`. Defaults to `heartbeat.interval` or `15000` if `heartbeat` is disabled. - `headers` - an optional array of header field names to include in server responses to the client. If set to `'*'` (without an array), allows all headers. Defaults to `null` (no headers). - `payload` - optional message payload settings where: - `maxChunkChars` - the maximum number of characters (after the full protocol object is converted to a string using `JSON.stringify()`) allowed in a single WebSocket message. This is important when using the protocol over a slow network (e.g. mobile) with large updates as the transmission time can exceed the timeout or heartbeat limits which will cause the client to disconnect. Defaults to `false` (no limit). - `heartbeat` - configures connection keep-alive settings where value can be: - `false` - no heartbeats. - an object with: - `interval` - time interval between heartbeat messages in milliseconds. Defaults to `15000` (15 seconds). - `timeout` - timeout in milliseconds after a heartbeat is sent to the client and before the client is considered disconnected by the server. Defaults to `5000` (5 seconds). - `maxConnections` - if specified, limits the number of simultaneous client connections. Defaults to `false`. - `origin` - an origin string or an array of origin strings incoming client requests must match for the connection to be permitted. Defaults to no origin validation. ## Server The plugin decorates the server with a few new methods for interacting with the incoming WebSocket connections. ### `await server.broadcast(message, [options])` Sends a message to all connected clients where: - `message` - the message sent to the clients. Can be any type which can be safely converted to string using `JSON.stringify()`. - `options` - optional object with the following: - `user` - optional user filter. When provided, the message will be sent only to authenticated sockets with `credentials.user` equal to `user`. Requires the `auth.index` options to be configured to `true`. Note that in a multi server deployment, only the client connected to the current server will receive the message. ### `server.subscription(path, [options])` Declares a subscription path client can subscribe to where: - `path` - an HTTP-like path. The path must begin with the `'/'` character. The path may contain path parameters as supported by the **hapi** route path parser. - `options` - an optional object where: - `filter` - a publishing filter function for making per-client connection decisions about which matching publication update should be sent to which client. The function uses the signature `async function(path, message, options)` where: - `path` - the path of the published update. The path is provided in case the subscription contains path parameters. - `message` - the message being published. - `options` - additional information about the subscription and client: - `socket` - the current socket being published to. - `credentials` - the client credentials if authenticated. - `params` - the parameters parsed from the publish message path if the subscription path contains parameters. - `internal` - the `internal` options data passed to the publish call, if defined. - the function must return a value of (or a promise that resolves into): - `true` - to proceed sending the message. - `false` - to skip sending the message. - `{ override }` - an override `message` to send to this `socket` instead of the published one. Note that if you want to modify `message`, you must clone it first or the changes will apply to all other sockets. - `auth` - the subscription authentication options with the following supported values: - `false` - no authentication required to subscribe. - a configuration object with the following optional keys: - `mode` - same as the **hapi** route auth modes: - `'required'` - authentication is required. This is the default value. - `'optional'` - authentication is optional. - `scope` - a string or array of string of authentication scope as supported by the **hapi** route authenticate configuration. - `entity` - the required credentials type as supported by the **hapi** route authentication configuration: - `'user'` - `'app'` - `'any'` - `index` - if `true`, authenticated socket with `user` property in `credentials` are mapped for usage in [`server.publish()`](#await-socketpublishpath-message) calls. Defaults to `false`. - `onSubscribe` - a method called when a client subscribes to this subscription endpoint using the signature `async function(socket, path, params)` where: - `socket` - the [`Socket`](#socket) object of the incoming connection. - `path` - the path the client subscribed to - `params` - the parameters parsed from the subscription request path if the subscription path definition contains parameters. - `onUnsubscribe` - a method called when a client unsubscribes from this subscription endpoint using the signature `async function(socket, path, params)` where: - `socket` - the [`Socket`](#socket) object of the incoming connection. - `path` - Path of the unsubscribed route. - `params` - the parameters parsed from the subscription request path if the subscription path definition contains parameters. ### `await server.publish(path, message, [options])` Sends a message to all the subscribed clients where: - `path` - the subscription path. The path is matched first against the available subscriptions added via `server.subscription()` and then against the specific path provided by each client at the time of registration (only matter when the subscription path contains parameters). When a match is found, the subscription `filter` function is called (if present) to further filter which client should receive which update. - `message` - the message sent to the clients. Can be any type which can be safely converted to string using `JSON.stringify()`. - `options` - optional object that may include - `internal` - Internal data that is passed to `filter` and may be used to filter messages on data that is not sent to the client. - `user` - optional user filter. When provided, the message will be sent only to authenticated sockets with `credentials.user` equal to `user`. Requires the subscription `auth.index` options to be configured to `true`. ### `await server.eachSocket(each, [options])` Iterates over all connected sockets, optionally filtering on those that have subscribed to a given subscription. This operation is synchronous. - `each` - Iteration method in the form `async function(socket)`. - `options` - Optional options object - `subscription` - When set to a string path, limits the results to sockets that are subscribed to that path. - `user` - optional user filter. When provided, the `each` method will be invoked with authenticated sockets with `credentials.user` equal to `user`. Requires the subscription `auth.index` options to be configured to `true`. ## Socket An object representing a client connection. ### `socket.id` A unique socket identifier. ### `socket.app` An object used to store application state per socket. Provides a safe namespace to avoid conflicts with the socket methods. ### `socket.auth` The socket authentication state if any. Similar to the normal **hapi** `request.auth` object where: - `isAuthenticated` - a boolean set to `true` when authenticated. - `credentials` - the authentication credentials used. - `artifacts` - authentication artifacts specific to the authentication strategy used. ### `socket.server` The socket's server reference. ### `socket.connection` The socket's connection reference. ### `socket.disconnect()` Closes a client connection. ### `socket.isOpen()` Returns `true` is the socket connection is in ready state, otherwise `false`. ### `await socket.send(message)` Sends a custom message to the client where: - `message` - the message sent to the client. Can be any type which can be safely converted to string using `JSON.stringify()`. ### `await socket.publish(path, message)` Sends a subscription update to a specific client where: - `path` - the subscription string. Note that if the client did not subscribe to the provided `path`, the client will ignore the update silently. - `message` - the message sent to the client. Can be any type which can be safely converted to string using `JSON.stringify()`. ### `await socket.revoke(path, message, [options])` Revokes a subscription and optionally includes a last update where: - `path` - the subscription string. Note that if the client is not subscribe to the provided `path`, the client will ignore the it silently. - `message` - an optional last subscription update sent to the client. Can be any type which can be safely converted to string using `JSON.stringify()`. Pass `null` to revoke the subscription without sending a last update. - `options` - optional settings: - `ignoreClosed` - ignore errors if the underlying websocket has been closed. Defaults to `false`. ## Request The following decorations are available on each request received via the nes connection. ### `request.socket` Provides access to the [`Socket`](#socket) object of the incoming connection. ## Client The client implements the **nes** protocol and provides methods for interacting with the server. It supports auto-connect by default as well as authentication. ### `new Client(url, [options])` Creates a new client object where: - `url` - the WebSocket address to connect to (e.g. `'wss://localhost:8000'`). - `option` - optional configuration object where: - `ws` - available only when the client is used in node.js and passed as-is to the [**ws** module](https://www.npmjs.com/package/ws). - `timeout` - server response timeout in milliseconds. Defaults to `false` (no timeout). ### `client.onError` A property used to set an error handler with the signature `function(err)`. Invoked whenever an error happens that cannot be associated with a pending request. ### `client.onConnect` A property used to set a handler for connection events (initial connection and subsequent reconnections) with the signature `function()`. ### `client.onDisconnect` A property used to set a handler for disconnection events with the signature `function(willReconnect, log)` where: - `willReconnect` - a boolean indicating if the client will automatically attempt to reconnect. - `log` - an object with the following optional keys: - `code` - the [RFC6455](https://tools.ietf.org/html/rfc6455#section-7.4.1) status code. - `explanation` - the [RFC6455](https://tools.ietf.org/html/rfc6455#section-7.4.1) explanation for the `code`. - `reason` - a human-readable text explaining the reason for closing. - `wasClean` - if `false`, the socket was closed abnormally. ### `client.onHeartbeatTimeout` A property used to set a handler for heartbeat timeout events with the signature `function(willReconnect)` where: - `willReconnect` - a boolean indicating if the client will automatically attempt to reconnect. Upon heartbeat timeout, the client will disconnect the websocket. However, the `client.onDisconnect()` property will only be called (if set) once the server has completed the closing handshake. Users may use this property to be notified immediately and take action (e.g. display a message in the browser). ### `client.onUpdate` A property used to set a custom message handler with the signature `function(message)`. Invoked whenever the server calls `server.broadcast()` or `socket.send()`. ### `await client.connect([options])` Connects the client to the server where: - `options` - an optional configuration object with the following options: - `auth` - sets the credentials used to authenticate. when the server is configured for `'token'` type authentication, the value is the token response received from the authentication endpoint (called manually by the application). When the server is configured for `'direct'` type authentication, the value is the credentials expected by the server for the specified authentication strategy used which typically means an object with headers (e.g. `{ headers: { authorization: 'Basic am9objpzZWNyZXQ=' } }`). - `reconnect` - a boolean that indicates whether the client should try to reconnect. Defaults to `true`. - `delay` - time in milliseconds to wait between each reconnection attempt. The delay time is cumulative, meaning that if the value is set to `1000` (1 second), the first wait will be 1 seconds, then 2 seconds, 3 seconds, until the `maxDelay` value is reached and then `maxDelay` is used. - `maxDelay` - the maximum delay time in milliseconds between reconnections. - `retries` - number of reconnection attempts. Defaults to `Infinity` (unlimited). - `timeout` - socket connection timeout in milliseconds. Defaults to the WebSocket implementation timeout default. ### `await client.disconnect()` Disconnects the client from the server and stops future reconnects. ### `client.id` The unique socket identifier assigned by the server. The value is set after the connection is established. ### `await client.request(options)` Sends an endpoint request to the server where: - `options` - value can be one of: - a string with the requested endpoint path or route id (defaults to a GET method). - an object with the following keys: - `path` - the requested endpoint path or route id. - `method` - the requested HTTP method (can also be any method string supported by the server). Defaults to `'GET'`. - `headers` - an object where each key is a request header and the value the header content. Cannot include an Authorization header. Defaults to no headers. - `payload` - the request payload sent to the server. Rejects with `Error` if the request failed. Resolves with object containing: - `payload` - the server response object. - `statusCode` - the HTTP response status code. - `headers` - an object containing the HTTP response headers returned by the server (based on the server configuration). ### `await client.message(message)` Sends a custom message to the server which is received by the server `onMessage` handler where: - `message` - the message sent to the server. Can be any type which can be safely converted to string using `JSON.stringify()`. ### `await client.subscribe(path, handler)` Subscribes to a server subscription where: - `path` - the requested subscription path. Paths are just like HTTP request paths (e.g. `'/item/5'` or `'/updates'` based on the paths supported by the server). - `handler` - the function used to receive subscription updates using the signature `function(message, flags)` where: - `message` - the subscription update sent by the server. - `flags` - an object with the following optional flags: - `revoked` - set to `true` when the message is the last update from the server due to a subscription revocation. Note that when `subscribe()` is called before the client connects, any server errors will be throw by `connect()`. ### `await client.unsubscribe(path, handler)` Cancels a subscription where: - `path` - the subscription path used to subscribe. - `handler` - remove a specific handler from a subscription or `null` to remove all handlers for the given path. ### `client.subscriptions()` Returns an array of the current subscription paths. ### `client.overrideReconnectionAuth(auth)` Sets or overrides the authentication credentials used to reconnect the client on disconnect when the client is configured to automatically reconnect, where: - `auth` - same as the `auth` option passed to [`client.connect()`](#await-clientconnectoptions). Returns `true` if reconnection is enabled, otherwise `false` (in which case the method was ignored). Note: this will not update the credentials on the server - use [`client.reauthenticate()`](#await-clientreauthenticateauth). ### `await client.reauthenticate(auth)` Will issue the `reauth` message to the server with updated `auth` details and also [override the reconnection information](#clientoverridereconnectionauthauth), if reconnection is enabled. The server will respond with an error and drop the connection in case the new `auth` credentials are invalid. Rejects with `Error` if the request failed. Resolves with `true` if the request succeeds. Note: when authentication has a limited lifetime, `reauthenticate()` should be called early enough to avoid the server dropping the connection. ### Errors When a client method returns or throws an error, the error is decorated with: - `type` - a string indicating the source of the error where: - `'disconnect'` - the socket disconnected before the request completed. - `'protocol'` - the client received an invalid message from the server violating the protocol. - `'server'` - an error response sent from the server. - `'timeout'` - a timeout event. - `'user'` - user error (e.g. incorrect use of the API). - `'ws'` - a socket error. ================================================ FILE: LICENSE.md ================================================ Copyright (c) 2016-2022, Project contributors Copyright (c) 2016-2020, Sideway Inc All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * The names of any contributors may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS OFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: PROTOCOL.md ================================================ # nes Protocol v2.4.x ## Message The nes protocol consists of JSON messages sent between the client and server. Each incoming request from the client to the server contains: - `type` - the message type: - `'ping'` - heartbeat response. - `'hello'` - connection initialization and authentication. - `'reauth'` - authentication refresh. - `'request'` - endpoint request. - `'sub'` - subscribe to a path. - `'unsub'` - unsubscribe from a path. - `'message'` - send custom message. - `id` - a unique per-client request id (number or string). - additional type-specific fields. Each outgoing request from the server to the client contains: - `type` - the message type: - `'ping'` - heartbeat request. - `'hello'` - connection initialization and authentication. - `'reauth'` - authentication refresh. - `'request'` - endpoint request. - `'sub'` - subscribe to a path. - `'unsub'` - unsubscribe from a path. - `'message'` - send custom message. - `'update'` - a custom message push from the server. - `'pub'` - a subscription update. - `'revoke'` - server forcedly removed the client from a subscription. - additional type-specific fields. If a message is too large to send as a single WebSocket update, it can be chunked into multiple messages. After constructing the JSON message string, the string is sliced into chunked and each is sent with the `'+'` prefix except for the last chunk sent with the `'!'` prefix. ## Errors When a message indicates an error, the message will include in addition to the message-specific fields: - `statusCode` - an HTTP equivalent status code (4xx, 5xx). - `headers` - optional headers related to the request. - `payload` - the error details which include: - `error` - the HTTP equivalent error message. - `message` - a description of the error. - additional error-specific fields. For example: ```js { type: 'hello', id: 1, statusCode: 401, payload: { error: 'Unauthorized', message: 'Unknown username or incorrect password' } } ``` ## Heartbeat Flow: `server` -> `client` -> `server` For cases where it is not possible for the TCP connection to determine if the connection is still active, the server sends a heartbeat message to the client every configured interval, and then expects the client to respond within a configured timeout. The server sends: - `type` - set to `'ping'`. For example: ```js { type: 'ping' } ``` When the client receives the message, it sends back: - `type` - set to `'ping'`. - `id` - a unique per-client request id (number or string). For example: ```js { type: 'ping', id: 6 } ``` ## Hello Flow: `client` -> `server` -> `client` Every client connection must first be initialized with a `hello` message. The client sends a message to the server with the following: - `type` - set to `'hello'`. - `id` - a unique per-client request id (number or string). - `version` - set to `'2'`. - `auth` - optional authentication credentials. Can be any value understood by the server. - `subs` - an optional array of strings indicating the path subscriptions the client is interested in. For example: ```js { type: 'hello', id: 1, version: '2', auth: { headers: { authorization: 'Basic am9objpzZWNyZXQ=' } }, subs: ['/a', '/b'] } ``` The server responds by sending a message back with the following: - `type` - set to `'hello'`. - `id` - the same `id` received from the client. - `heartbeat` - the server heartbeat configuration which can be: - `false` - no heartbeats will be sent. - an object with: - `interval` - the heartbeat interval in milliseconds. - `timeout` - the time from sending a heartbeat to the client until a response is expected before a connection is considered closed by the server. - `socket` - the server generated socket identifier for the connection. Note: the client should assume the connection is closed if it has not heard from the server in heartbeat.interval + heartbeat.timeout. For example: ```js { type: 'hello', id: 1, heartbeat: { interval: 15000, timeout: 5000 }, socket: 'abc-123' } ``` If the request failed (including subscription errors), the server includes the [standard error fields](#errors). For example: ```js { type: 'hello', id: 1, statusCode: 401, payload: { error: 'Unauthorized', message: 'Unknown username or incorrect password' } } ``` If the request fails due to a subscription error, the server will include the failed subscription path in the response: - `path` - the requested path which failed to subscribe. For example: ```js { type: 'hello', id: 1, path: '/a', statusCode: 403, payload: { error: 'Subscription not found' } } ``` ## Reauthenticate Flow: `client` -> `server` -> `client` When the authentication credentials have an expiry, the client may want to update the authentication information for the connection: - `type` - set to `'reauth'`. - `id` - a unique per-client request id (number or string). - `auth` - authentication credentials. Can be any value understood by the server. For example: ```js { type: 'reauth', id: 1, auth: { headers: { authorization: 'Basic am9objpzZWNyZXQ=' } } } ``` The server responds by sending a message back with the following: - `type` - set to `'reauth'`. - `id` - the same `id` received from the client. For example: ```js { type: 'reauth', id: 1 } ``` If the request failed, the server includes the [standard error fields](#errors). For example: ```js { type: 'reauth', id: 1, statusCode: 401, payload: { error: 'Unauthorized', message: 'Unknown username or incorrect password' } } ``` ## Request Flow: `client` -> `server` -> `client` Request a resource from the server where: - `type` - set to `'request'`. - `id` - a unique per-client request id (number or string). - `method` - the corresponding HTTP method (e.g. `'GET'`). - `path` - the requested resource (can be an HTTP path or resource name). - `headers` - an optional object with the request headers (each header name is a key with a corresponding value). - `payload` - an optional value to send with the request. For example: ```js { type: 'request', id: 2, method: 'POST', path: '/item/5', payload: { id: 5, status: 'done' } } ``` The server response includes: - `type` - set to `'request'`. - `id` - the same `id` received from the client. - `statusCode` - an HTTP equivalent status code. - `payload` - the requested resource. - `headers` - optional headers related to the request (e.g. `{ 'content-type': 'text/html; charset=utf-8' }'). For example: ```js { type: 'request', id: 2, statusCode: 200, payload: { status: 'ok' } } ``` If the request fails, the `statusCode`, `headers`, and `payload` fields will comply with the [standard error values](#errors). ## Message Flow: `client` -> `server` [-> `client`] Sends a custom message to the server where: - `type` - set to `'message'`. - `id` - a unique per-client request id (number or string). - `message` - any value (string, object, etc.). For example: ```js { type: 'message', id: 3, message: 'hi' } ``` The server response includes: - `type` - set to `'message'`. - `id` - the same `id` received from the client. - `message` - any value (string, object, etc.). For example: ```js { type: 'message', id: 3, message: 'hello back' } ``` If the request fails, the response will include the [standard error fields](#errors). ## Subscribe Flow: `client` -> `server` [-> `client`] Sends a subscription request to the server: - `type` - set to `'sub'`. - `id` - a unique per-client request id (number or string). - `path` - the requested subscription path. For example: ```js { type: 'sub', id: 4, path: '/box/blue' } ``` The server response includes: - `type` - set to `'sub'`. - `id` - the same `id` received from the client. - `path` - the requested path which failed to subscribe. - the [standard error fields](#errors) if failed. For example: ```js { type: 'sub', id: 4, path: '/box/blue', statusCode: 403, payload: { error: 'Forbidden' } } ``` ## Unsubscribe Flow: `client` -> `server` -> `client` Unsubscribe from a server subscription: - `type` - set to `'unsub'`. - `id` - a unique per-client request id (number or string). - `path` - the subscription path. For example: ```js { type: 'unsub', id: 5, path: '/box/blue' } ``` The server response includes: - `type` - set to `'sub'`. - `id` - the same `id` received from the client. - the [standard error fields](#errors) if failed. For example: ```js { type: 'unsub', id: 5 } ``` ## Update Flow: `server` -> `client` A custom message sent from the server to a specific client or to all connected clients: - `type` - set to `'update'`. - `message` - any value (string, object, etc.). ```js { type: 'update', message: { some: 'message' } } ``` ## Publish Flow: `server` -> `client` A message sent from the server to all subscribed clients: - `type` - set to `'pub'`. - `path` - the subscription path. - `message` - any value (string, object, etc.). ```js { type: 'pub', path: '/box/blue', message: { status: 'closed' } } ``` ## Revoked Flow: `server` -> `client` The server forcefully removed the client from a subscription: - `type` - set to `'revoke'`. - `path` - the subscription path. - `message` - any value (string, object, etc.). An optional last message sent to the client for the specified subscription. For example: ```js { type: 'revoke', path: '/box/blue', message: { reason: 'channel permissions changed' } } ``` ================================================ FILE: README.md ================================================ # @hapi/nes #### WebSocket adapter plugin for hapi routes. **nes** is part of the **hapi** ecosystem and was designed to work seamlessly with the [hapi web framework](https://hapi.dev) and its other components (but works great on its own or with other frameworks). If you are using a different web framework and find this module useful, check out [hapi](https://hapi.dev) – they work even better together. ### Visit the [hapi.dev](https://hapi.dev) Developer Portal for tutorials, documentation, and support ## Useful resources - [Documentation and API](https://hapi.dev/family/nes/) - [Version status](https://hapi.dev/resources/status/#nes) (builds, dependencies, node versions, licenses, eol) - [Changelog](https://hapi.dev/family/nes/changelog/) - [Project policies](https://hapi.dev/policies/) - [Free and commercial support options](https://hapi.dev/support/) ================================================ FILE: lib/client.d.ts ================================================ import type { ClientRequestArgs } from "http"; import type { ClientOptions } from "ws"; // Same as exported type in @hapi/hapi v20 type HTTP_METHODS = 'ACL' | 'BIND' | 'CHECKOUT' | 'CONNECT' | 'COPY' | 'DELETE' | 'GET' | 'HEAD' | 'LINK' | 'LOCK' | 'M-SEARCH' | 'MERGE' | 'MKACTIVITY' | 'MKCALENDAR' | 'MKCOL' | 'MOVE' | 'NOTIFY' | 'OPTIONS' | 'PATCH' | 'POST' | 'PROPFIND' | 'PROPPATCH' | 'PURGE' | 'PUT' | 'REBIND' | 'REPORT' | 'SEARCH' | 'SOURCE' | 'SUBSCRIBE' | 'TRACE' | 'UNBIND' | 'UNLINK' | 'UNLOCK' | 'UNSUBSCRIBE'; type ErrorType = ( 'timeout' | 'disconnect' | 'server' | 'protocol' | 'ws' | 'user' ); type ErrorCodes = { 1000: 'Normal closure', 1001: 'Going away', 1002: 'Protocol error', 1003: 'Unsupported data', 1004: 'Reserved', 1005: 'No status received', 1006: 'Abnormal closure', 1007: 'Invalid frame payload data', 1008: 'Policy violation', 1009: 'Message too big', 1010: 'Mandatory extension', 1011: 'Internal server error', 1015: 'TLS handshake' }; type NesLog = { readonly code: keyof ErrorCodes; readonly explanation: ErrorCodes[keyof ErrorCodes] | 'Unknown'; readonly reason: string; readonly wasClean: boolean; readonly willReconnect: boolean; readonly wasRequested: boolean; } export interface NesError extends Error { type: ErrorType; isNes: true; statusCode?: number; data?: any; headers?: Record; path?: string; } export interface ClientConnectOptions { /** * sets the credentials used to authenticate. * when the server is configured for * * - `'token'` type authentication, the value * is the token response received from the * authentication endpoint (called manually by * the application). When the server is * configured for `'direct'` type * authentication, the value is the credentials * expected by the server for the specified * authentication strategy used which typically * means an object with headers * (e.g. `{ headers: { authorization: 'Basic am9objpzZWNyZXQ=' } }`). */ auth?: string | { headers?: Record; payload?: Record; }; /** * A boolean that indicates whether the client * should try to reconnect. Defaults to `true`. */ reconnect?: boolean; /** * Time in milliseconds to wait between each * reconnection attempt. The delay time is * cumulative, meaning that if the value is set * to `1000` (1 second), the first wait will be * 1 seconds, then 2 seconds, 3 seconds, until * the `maxDelay` value is reached and then * `maxDelay` is used. */ delay?: number; /** * The maximum delay time in milliseconds * between reconnections. */ maxDelay?: number; /** * number of reconnection attempts. Defaults to * `Infinity` (unlimited). */ retries?: number; /** * socket connection timeout in milliseconds. * Defaults to the WebSocket implementation * timeout default. */ timeout?: number; } type NesReqRes = { payload: R; statusCode: number; headers: Record; } export interface ClientRequestOptions { /** * The requested endpoint path or route id. */ path: string; /** * The requested HTTP method (can also be any * method string supported by the server). * Defaults to `'GET'`. */ method?: Omit, 'HEAD' | 'head'>; /** * An object where each key is a request header * and the value the header content. Cannot * include an Authorization header. Defaults to * no headers. */ headers?: Record; /** * The request payload sent to the server. */ payload?: any; } export interface NesSubHandler { ( message: unknown, flags: { /** * Set to `true` when the message is the * last update from the server due to a * subscription revocation. */ revoked?: boolean; } ): void; } /** * Creates a new client object * * https://github.com/hapijs/nes/blob/master/API.md#client-5 */ export class Client { /** * https://github.com/hapijs/nes/blob/master/API.md#new-clienturl-options * @param url * @param options */ constructor( url: `ws://${string}` | `wss://${string}`, options?: { ws?: ClientOptions | ClientRequestArgs; timeout?: number | boolean; }); /** * The unique socket identifier assigned by the * server. The value is set after the * connection is established. */ readonly id: string | null; /** * A property set by the developer to handle * errors. Invoked whenever an error happens * that cannot be associated with a pending * request. * * https://github.com/hapijs/nes/blob/master/API.md#clientonerror */ onError(err: NesError): void; /** * A property set by the developer used to set * a handler for connection events (initial * connection and subsequent reconnections) * * https://github.com/hapijs/nes/blob/master/API.md#clientonconnect */ onConnect(): void; /** * A property set by the developer used to set * a handler for disconnection events * * https://github.com/hapijs/nes/blob/master/API.md#clientondisconnect * * @param willReconnect A boolean indicating if * the client will automatically attempt to * reconnect * @param log A log object containing * information about the disconnection */ onDisconnect(willReconnect: boolean, log: NesLog): void; /** * A property set by the developer used to set * a handler for heartbeat timeout events * * https://github.com/hapijs/nes/blob/master/API.md#clientonheartbeattimeout * * @param willReconnect A boolean indicating if * the client will automatically attempt to * reconnect */ onHeartbeatTimeout(willReconnect: boolean): void; /** * A property set by the developer used to set * a custom message handler. Invoked whenever * the server calls `server.broadcast()` or * `socket.send()`. * * https://github.com/hapijs/nes/blob/master/API.md#clientonupdate * * @param message */ onUpdate(message: unknown): void; /** * Connects the client to the server * * https://github.com/hapijs/nes/blob/master/API.md#await-clientconnectoptions */ connect(options?: ClientConnectOptions): Promise; /** * Disconnects the client from the server and * stops future reconnects. * * https://github.com/hapijs/nes/blob/master/API.md#await-clientdisconnect */ disconnect(): Promise; /** * Sends an endpoint request to the server. * This overload will perform a `GET` request by * default. * * Rejects with `Error` if the request failed. * * https://github.com/hapijs/nes/blob/master/API.md#await-clientrequestoptions * * @param path The endpoint path */ request (path: string): Promise>; /** * Sends an endpoint request to the server. * * Rejects with `Error` if the request failed. * * https://github.com/hapijs/nes/blob/master/API.md#await-clientrequestoptions * * @param options The request options */ request (options: ClientRequestOptions): Promise>; /** * Sends a custom message to the server which * is received by the server `onMessage` handler * * https://github.com/hapijs/nes/blob/master/API.md#await-clientmessagemessage * * @param message The message sent to the * server. Can be any type which can be safely * converted to string using `JSON.stringify()`. */ message (message: unknown): Promise; /** * Subscribes to a server subscription * * https://github.com/hapijs/nes/blob/master/API.md#await-clientsubscribepath-handler * * @param path The requested subscription path. * Paths are just like HTTP request paths (e.g. * `'/item/5'` or `'/updates'` based on the * paths supported by the server). * * @param handler The function used to receive subscription updates */ subscribe(path: string, handler: NesSubHandler): Promise; /** * Cancels a subscription * * https://github.com/hapijs/nes/blob/master/API.md#await-clientunsubscribepath-handler * * @param path the subscription path used to subscribe * @param handler remove a specific handler from a * subscription or `null` to remove all handlers for * the given path */ unsubscribe(path: string, handler?: NesSubHandler): Promise; /** * Returns an array of the current subscription paths. * * https://github.com/hapijs/nes/blob/master/API.md#clientsubscriptions * */ subscriptions(): string[]; /** * Sets or overrides the authentication credentials used * to reconnect the client on disconnect when the client * is configured to automatically reconnect * * Returns `true` if reconnection is enabled, otherwise * `false` (in which case the method was ignored). * * Note: this will not update the credentials on the * server - use `client.reauthenticate()` * * https://github.com/hapijs/nes/blob/master/API.md#clientoverridereconnectionauthauth * * @param auth same as the `auth` option passed to * `client.connect()` */ overrideReconnectionAuth(auth: ClientConnectOptions['auth']): boolean; /** * Will issue the `reauth` message to the server with * updated `auth` details and also override the * reconnection information, if reconnection is enabled. * The server will respond with an error and drop the * connection in case the new `auth` credentials are * invalid. * * Rejects with `Error` if the request failed. * * Resolves with `true` if the request succeeds. * * Note: when authentication has a limited lifetime, * `reauthenticate()` should be called early enough to * avoid the server dropping the connection. * * https://github.com/hapijs/nes/blob/master/API.md#await-clientreauthenticateauth * * @param auth same as the `auth` option passed to * `client.connect()` */ reauthenticate(auth: ClientConnectOptions['auth']): Promise; } ================================================ FILE: lib/client.js ================================================ 'use strict'; /* (hapi)nes WebSocket Client (https://github.com/hapijs/nes) Copyright (c) 2015-2016, Eran Hammer and other contributors BSD Licensed */ /* eslint no-undef: 0 */ (function (root, factory) { // $lab:coverage:off$ if (typeof exports === 'object' && typeof module === 'object') { module.exports = factory(); // Export if used as a module } else if (typeof define === 'function' && define.amd) { define(factory); } else if (typeof exports === 'object') { exports.nes = factory(); } else { root.nes = factory(); } // $lab:coverage:on$ })(/* $lab:coverage:off$ */ typeof window !== 'undefined' ? window : global /* $lab:coverage:on$ */, () => { // Utilities const version = '2'; const ignore = function () { }; const stringify = function (message) { try { return JSON.stringify(message); } catch (err) { throw new NesError(err, errorTypes.USER); } }; const nextTick = function (callback) { return (err) => { setTimeout(() => callback(err), 0); }; }; // NesError types const errorTypes = { TIMEOUT: 'timeout', DISCONNECT: 'disconnect', SERVER: 'server', PROTOCOL: 'protocol', WS: 'ws', USER: 'user' }; const NesError = function (err, type) { if (typeof err === 'string') { err = new Error(err); } err.type = type; err.isNes = true; try { throw err; // ensure stack trace for IE11 } catch (withStack) { return withStack; } }; // Error codes const errorCodes = { 1000: 'Normal closure', 1001: 'Going away', 1002: 'Protocol error', 1003: 'Unsupported data', 1004: 'Reserved', 1005: 'No status received', 1006: 'Abnormal closure', 1007: 'Invalid frame payload data', 1008: 'Policy violation', 1009: 'Message too big', 1010: 'Mandatory extension', 1011: 'Internal server error', 1015: 'TLS handshake' }; // Client const Client = function (url, options) { options = options || {}; this._isBrowser = Client.isBrowser(); if (!this._isBrowser) { options.ws = options.ws || {}; if (options.ws.maxPayload === undefined) { options.ws.maxPayload = 0; // Override default 100Mb limit in ws module to avoid breaking change } } // Configuration this._url = url; this._settings = options; this._heartbeatTimeout = false; // Server heartbeat configuration // State this._ws = null; this._reconnection = null; this._reconnectionTimer = null; this._ids = 0; // Id counter this._requests = {}; // id -> { resolve, reject, timeout } this._subscriptions = {}; // path -> [callbacks] this._heartbeat = null; this._packets = []; this._disconnectListeners = null; this._disconnectRequested = false; // Events this.onError = (err) => console.error(err); // General error handler (only when an error cannot be associated with a request) this.onConnect = ignore; // Called whenever a connection is established this.onDisconnect = ignore; // Called whenever a connection is lost: function(willReconnect) this.onHeartbeatTimeout = ignore; // Called when a heartbeat timeout will cause a disconnection this.onUpdate = ignore; // Public properties this.id = null; // Assigned when hello response is received }; Client.WebSocket = /* $lab:coverage:off$ */ (typeof WebSocket === 'undefined' ? null : WebSocket); /* $lab:coverage:on$ */ Client.isBrowser = function () { // $lab:coverage:off$ return typeof WebSocket !== 'undefined' && typeof window !== 'undefined'; // $lab:coverage:on$ }; Client.prototype.connect = function (options) { options = options || {}; if (this._reconnection) { return Promise.reject(new NesError('Cannot connect while client attempts to reconnect', errorTypes.USER)); } if (this._ws) { return Promise.reject(new NesError('Already connected', errorTypes.USER)); } if (options.reconnect !== false) { // Defaults to true this._reconnection = { // Options: reconnect, delay, maxDelay wait: 0, delay: options.delay || 1000, // 1 second maxDelay: options.maxDelay || 5000, // 5 seconds retries: options.retries || Infinity, // Unlimited settings: { auth: options.auth, timeout: options.timeout } }; } else { this._reconnection = null; } return new Promise((resolve, reject) => { this._connect(options, true, (err) => { if (err) { return reject(err); } return resolve(); }); }); }; Client.prototype._connect = function (options, initial, next) { const ws = this._isBrowser ? new Client.WebSocket(this._url) : new Client.WebSocket(this._url, this._settings.ws); this._ws = ws; clearTimeout(this._reconnectionTimer); this._reconnectionTimer = null; const reconnect = (event) => { if (ws.onopen) { finalize(new NesError('Connection terminated while waiting to connect', errorTypes.WS)); } const wasRequested = this._disconnectRequested; // Get value before _cleanup() this._cleanup(); const log = { code: event.code, explanation: errorCodes[event.code] || 'Unknown', reason: event.reason, wasClean: event.wasClean, willReconnect: this._willReconnect(), wasRequested }; this.onDisconnect(log.willReconnect, log); this._reconnect(); }; const finalize = (err) => { if (next) { // Call only once when connect() is called const nextHolder = next; next = null; return nextHolder(err); } return this.onError(err); }; const timeoutHandler = () => { this._cleanup(); finalize(new NesError('Connection timed out', errorTypes.TIMEOUT)); if (initial) { return this._reconnect(); } }; const timeout = (options.timeout ? setTimeout(timeoutHandler, options.timeout) : null); ws.onopen = () => { clearTimeout(timeout); ws.onopen = null; this._hello(options.auth) .then(() => { this.onConnect(); finalize(); }) .catch((err) => { if (err.path) { delete this._subscriptions[err.path]; } this._disconnect(() => nextTick(finalize)(err), true); // Stop reconnection when the hello message returns error }); }; ws.onerror = (event) => { clearTimeout(timeout); if (this._willReconnect()) { return reconnect(event); } this._cleanup(); const error = new NesError('Socket error', errorTypes.WS); return finalize(error); }; ws.onclose = reconnect; ws.onmessage = (message) => { return this._onMessage(message); }; }; Client.prototype.overrideReconnectionAuth = function (auth) { if (!this._reconnection) { return false; } this._reconnection.settings.auth = auth; return true; }; Client.prototype.reauthenticate = function (auth) { this.overrideReconnectionAuth(auth); const request = { type: 'reauth', auth }; return this._send(request, true); }; Client.prototype.disconnect = function () { return new Promise((resolve) => this._disconnect(resolve, false)); }; Client.prototype._disconnect = function (next, isInternal) { this._reconnection = null; clearTimeout(this._reconnectionTimer); this._reconnectionTimer = null; const requested = this._disconnectRequested || !isInternal; // Retain true if (this._disconnectListeners) { this._disconnectRequested = requested; this._disconnectListeners.push(next); return; } if (!this._ws || (this._ws.readyState !== Client.WebSocket.OPEN && this._ws.readyState !== Client.WebSocket.CONNECTING)) { return next(); } this._disconnectRequested = requested; this._disconnectListeners = [next]; this._ws.close(); }; Client.prototype._cleanup = function () { if (this._ws) { const ws = this._ws; this._ws = null; if (ws.readyState !== Client.WebSocket.CLOSED && ws.readyState !== Client.WebSocket.CLOSING) { ws.close(); } ws.onopen = null; ws.onclose = null; ws.onerror = ignore; ws.onmessage = null; } this._packets = []; this.id = null; clearTimeout(this._heartbeat); this._heartbeat = null; // Flush pending requests const error = new NesError('Request failed - server disconnected', errorTypes.DISCONNECT); const requests = this._requests; this._requests = {}; const ids = Object.keys(requests); for (let i = 0; i < ids.length; ++i) { const id = ids[i]; const request = requests[id]; clearTimeout(request.timeout); request.reject(error); } if (this._disconnectListeners) { const listeners = this._disconnectListeners; this._disconnectListeners = null; this._disconnectRequested = false; listeners.forEach((listener) => listener()); } }; Client.prototype._reconnect = function () { // Reconnect const reconnection = this._reconnection; if (!reconnection) { return; } if (this._reconnectionTimer) { return; } if (reconnection.retries < 1) { return this._disconnect(ignore, true); // Clear _reconnection state } --reconnection.retries; reconnection.wait = reconnection.wait + reconnection.delay; const timeout = Math.min(reconnection.wait, reconnection.maxDelay); this._reconnectionTimer = setTimeout(() => { this._connect(reconnection.settings, false, (err) => { if (err) { this.onError(err); return this._reconnect(); } }); }, timeout); }; Client.prototype.request = function (options) { if (typeof options === 'string') { options = { method: 'GET', path: options }; } const request = { type: 'request', method: options.method || 'GET', path: options.path, headers: options.headers, payload: options.payload }; return this._send(request, true); }; Client.prototype.message = function (message) { const request = { type: 'message', message }; return this._send(request, true); }; Client.prototype._isReady = function () { return this._ws && this._ws.readyState === Client.WebSocket.OPEN; }; Client.prototype._send = function (request, track) { if (!this._isReady()) { return Promise.reject(new NesError('Failed to send message - server disconnected', errorTypes.DISCONNECT)); } request.id = ++this._ids; try { var encoded = stringify(request); } catch (err) { return Promise.reject(err); } // Ignore errors if (!track) { try { this._ws.send(encoded); return Promise.resolve(); } catch (err) { return Promise.reject(new NesError(err, errorTypes.WS)); } } // Track errors const record = { resolve: null, reject: null, timeout: null }; const promise = new Promise((resolve, reject) => { record.resolve = resolve; record.reject = reject; }); if (this._settings.timeout) { record.timeout = setTimeout(() => { record.timeout = null; return record.reject(new NesError('Request timed out', errorTypes.TIMEOUT)); }, this._settings.timeout); } this._requests[request.id] = record; try { this._ws.send(encoded); } catch (err) { clearTimeout(this._requests[request.id].timeout); delete this._requests[request.id]; return Promise.reject(new NesError(err, errorTypes.WS)); } return promise; }; Client.prototype._hello = function (auth) { const request = { type: 'hello', version }; if (auth) { request.auth = auth; } const subs = this.subscriptions(); if (subs.length) { request.subs = subs; } return this._send(request, true); }; Client.prototype.subscriptions = function () { return Object.keys(this._subscriptions); }; Client.prototype.subscribe = function (path, handler) { if (!path || path[0] !== '/') { return Promise.reject(new NesError('Invalid path', errorTypes.USER)); } const subs = this._subscriptions[path]; if (subs) { // Already subscribed if (subs.indexOf(handler) === -1) { subs.push(handler); } return Promise.resolve(); } this._subscriptions[path] = [handler]; if (!this._isReady()) { // Queued subscription return Promise.resolve(); } const request = { type: 'sub', path }; const promise = this._send(request, true); promise.catch((ignoreErr) => { delete this._subscriptions[path]; }); return promise; }; Client.prototype.unsubscribe = function (path, handler) { if (!path || path[0] !== '/') { return Promise.reject(new NesError('Invalid path', errorTypes.USER)); } const subs = this._subscriptions[path]; if (!subs) { return Promise.resolve(); } let sync = false; if (!handler) { delete this._subscriptions[path]; sync = true; } else { const pos = subs.indexOf(handler); if (pos === -1) { return Promise.resolve(); } subs.splice(pos, 1); if (!subs.length) { delete this._subscriptions[path]; sync = true; } } if (!sync || !this._isReady()) { return Promise.resolve(); } const request = { type: 'unsub', path }; const promise = this._send(request, true); promise.catch(ignore); // Ignoring errors as the subscription handlers are already removed return promise; }; Client.prototype._onMessage = function (message) { this._beat(); let data = message.data; const prefix = data[0]; if (prefix !== '{') { this._packets.push(data.slice(1)); if (prefix !== '!') { return; } data = this._packets.join(''); this._packets = []; } if (this._packets.length) { this._packets = []; this.onError(new NesError('Received an incomplete message', errorTypes.PROTOCOL)); } try { var update = JSON.parse(data); } catch (err) { return this.onError(new NesError(err, errorTypes.PROTOCOL)); } // Recreate error let error = null; if (update.statusCode && update.statusCode >= 400) { error = new NesError(update.payload.message || update.payload.error || 'Error', errorTypes.SERVER); error.statusCode = update.statusCode; error.data = update.payload; error.headers = update.headers; error.path = update.path; } // Ping if (update.type === 'ping') { return this._send({ type: 'ping' }, false).catch(ignore); // Ignore errors } // Broadcast and update if (update.type === 'update') { return this.onUpdate(update.message); } // Publish or Revoke if (update.type === 'pub' || update.type === 'revoke') { const handlers = this._subscriptions[update.path]; if (update.type === 'revoke') { delete this._subscriptions[update.path]; } if (handlers && update.message !== undefined) { const flags = {}; if (update.type === 'revoke') { flags.revoked = true; } for (let i = 0; i < handlers.length; ++i) { handlers[i](update.message, flags); } } return; } // Lookup request (message must include an id from this point) const request = this._requests[update.id]; if (!request) { return this.onError(new NesError('Received response for unknown request', errorTypes.PROTOCOL)); } clearTimeout(request.timeout); delete this._requests[update.id]; const next = (err, args) => { if (err) { return request.reject(err); } return request.resolve(args); }; // Response if (update.type === 'request') { return next(error, { payload: update.payload, statusCode: update.statusCode, headers: update.headers }); } // Custom message if (update.type === 'message') { return next(error, { payload: update.message }); } // Authentication if (update.type === 'hello') { this.id = update.socket; if (update.heartbeat) { this._heartbeatTimeout = update.heartbeat.interval + update.heartbeat.timeout; this._beat(); // Call again once timeout is set } return next(error); } if (update.type === 'reauth') { return next(error, true); } // Subscriptions if (update.type === 'sub' || update.type === 'unsub') { return next(error); } next(new NesError('Received invalid response', errorTypes.PROTOCOL)); return this.onError(new NesError('Received unknown response type: ' + update.type, errorTypes.PROTOCOL)); }; Client.prototype._beat = function () { if (!this._heartbeatTimeout) { return; } clearTimeout(this._heartbeat); this._heartbeat = setTimeout(() => { this.onError(new NesError('Disconnecting due to heartbeat timeout', errorTypes.TIMEOUT)); this.onHeartbeatTimeout(this._willReconnect()); this._ws.close(); }, this._heartbeatTimeout); }; Client.prototype._willReconnect = function () { return !!(this._reconnection && this._reconnection.retries >= 1); }; // Expose interface return { Client }; }); ================================================ FILE: lib/index.d.ts ================================================ import * as Hapi from '@hapi/hapi'; import * as Iron from '@hapi/iron'; import { Client } from './client'; import { ClientConnectOptions, ClientRequestOptions, ErrorCodes, ErrorType, NesError, NesLog, NesReqRes, NesSubHandler } from './client'; export namespace Nes { export { ClientConnectOptions, ClientRequestOptions, ErrorCodes, ErrorType, NesError, NesLog, NesReqRes, NesSubHandler } export interface SocketAuthObject< U extends object = Hapi.UserCredentials, A extends object = Hapi.AuthCredentials, > { isAuthenticated: boolean; credentials: Hapi.AuthCredentials | null; artifacts: Hapi.AuthArtifacts | null; } export interface Socket< App extends object = {}, Auth extends SocketAuthObject = SocketAuthObject > { id: string, app: App, auth: Auth, info: { remoteAddress: string, remotePort: number, 'x-forwarded-for'?: string, } server: Hapi.Server, disconnect(): Promise, send(message: unknown): Promise, publish(path: string, message: unknown): Promise, revoke( path: string, message?: unknown | null, options?: { ignoreClose?: boolean, } ): Promise, isOpen(): boolean, } export interface ClientOpts { onDisconnect?: ( willReconnect: boolean, log: { code: number, explanation: string, reason: string, wasClean: string, willReconnect: boolean, wasRequested: boolean, } ) => void } export interface BroadcastOptions { /** * Optional user filter. When provided, the * message will be sent only to * authenticated sockets with * `credentials.user` equal to `user`. * Requires the `auth.index` options to be * configured to `true`. */ user?: string } type FilterReturn = ( boolean | { /** * an override `message` to send to this `socket` * instead of the published one. Note that if you * want to modify `message`, you must clone it first * or the changes will apply to all other sockets. */ override: unknown } ) export interface SubscriptionOptions> { /** * Publishing filter function for making per-client * connection decisions about which matching publication * update should be sent to which client. * @param path The path of the published update. The path * is provided in case the subscription contains path * parameters * @param message The `JSON.stringify()` compliant * message being published * @param options Additional information about the * subscription and client * @returns */ filter?: ( path: string, message: unknown, options: { socket: S, credentials?: S['auth']['credentials'], /** * The parameters parsed from the publish message * path if the subscription path contains * parameters. */ params?: unknown, /** * The internal options data passed to the * `server.publish()` call, if defined. */ internal: unknown }, ) => (FilterReturn | Promise), /** * A method called when a client subscribes to this * subscription endpoint * * @param socket The `Socket` object of incoming * connection * @param path The path the client subscribed to * @param params The parameters parsed from the * subscription request path if the subscription path * definition contains parameters. * @returns */ onSubscribe?: ( socket: Socket, path: string, params?: unknown ) => void, /** * A method called when a client unsubscribes from this subscription endpoint * @param socket The `Socket` object of incoming * connection * @param path The path the client subscribed to * @param params The parameters parsed from the * subscription request path if the subscription path * definition contains parameters. * @returns */ onUnsubscribe?: ( socket: Socket, path: string, params?: unknown ) => void, /** * The subscription authentication options */ auth?: boolean | { /** * Same as the ***hapi*** auth modes. */ mode?: 'required' | 'optional', /** * Same as the ***hapi*** auth scopes. */ scope?: string | string[], /** * Same as the ***hapi*** auth entities. */ entity?: 'user' | 'app' | 'any', /** * if `true`, authenticated socket with `user` * property in `credentials` are mapped for usage * in `server.publish()` calls. Defaults to `false`. */ index?: boolean, } } export interface PublishOptions { /** * Optional user filter. When provided, the message will * be sent only to authenticated sockets with * `credentials.user` equal to `user`. Requires the * subscription `auth.index` options to be configured to * `true`. */ user?: string, /** * Internal data that is passed to `filter` and may be * used to filter messages on data that is not sent to * the client. */ internal?: unknown } export interface EachSocketOptions { /** * When set to a string path, limits the results to sockets that are subscribed to that path. */ subscription?: string, /** * Optional user filter. When provided, the `each` method * will be invoked with authenticated sockets with * `credentials.user` equal to `user`. Requires the * subscription `auth.index` options to be configured to * `true`. */ user?: string } /** * Plugin options * * https://github.com/hapijs/nes/blob/master/API.md#registration */ export interface PluginOptions< App extends object = {}, Auth extends SocketAuthObject = SocketAuthObject > { /** * A function invoked for each incoming connection * @param socket The `Socket` object of incoming * connection */ onConnection?: (socket: Socket) => void /** * A function invoked for each disconnection * @param socket The `Socket` object of incoming * connection */ onDisconnection?: (socket: Socket) => void /** * A function used to receive custom client messages * @param message The message sent by the client * @returns */ onMessage?: ( socket: Socket, message: unknown ) => void /** * Optional plugin authentication options. The details of * this object do imply quiet a bit of logic, so it is * best to see the documentation for more information. * * https://github.com/hapijs/nes/blob/master/API.md#registration */ auth?: false | { endpoint?: string id?: string type?: 'cookie' | 'token' | 'direct', route?: Hapi.RouteOptions['auth'], cookie?: string, isSecure?: boolean, isHttpOnly?: boolean, isSameSite?: 'Strict' | 'Lax' | false, path?: string | null, domain?: string | null, ttl?: number | null, iron?: Iron.SealOptions, password?: Iron.Password | Iron.password.Secret, index?: boolean, timeout?: number | false, maxConnectionsPerUser?: number | false, minAuthVerifyInterval?: number | false, }, /** * An optional array of header field names to include in * server responses to the client. If set to `'*'` * (without an array), allows all headers. Defaults to * `null` (no headers). */ headers?: string[] | '*' | null, /** * Optional message payload */ payload?: { /** * the maximum number of characters (after the full * protocol object is converted to a string using * `JSON.stringify()`) allowed in a single WebSocket * message. This is important when using the protocol * over a slow network (e.g. mobile) with large * updates as the transmission time can exceed the * timeout or heartbeat limits which will cause the * client to disconnect. Defaults to `false` * (no limit). */ maxChunkChars?: number | false, }, /** * Configures connection keep-alive settings. * When set to `false`, the server will not send * heartbeats. Defaults to: * * ```js * { * interval: 15000, * timeout: 5000 * } * ``` */ heartbeat?: false | { /** * The time interval between heartbeat messages in * milliseconds. Defaults to `15000` (15 seconds). */ interval: number, /** * timeout in milliseconds after a heartbeat is sent * to the client and before the client is considered * disconnected by the server. Defaults to `5000` * (5 seconds). */ timeout?: number, }, /** * If specified, limits the number of simultaneous client * connections. Defaults to `false`. */ maxConnections?: number | false, /** * An origin string or an array of origin strings * incoming client requests must match for the connection * to be permitted. Defaults to no origin validation. */ origins?: string | string[] } } export { Client } export const plugin: Hapi.Plugin; declare module '@hapi/hapi' { interface Server { /** * Sends a message to all connected clients * where: * * https://hapi.dev/module/nes/api/?v=13.0.1#await-serverbroadcastmessage-options * * @param message The message sent to the * clients. Can be any type which can be * safely converted to string using `JSON. * stringify()`. * @param options An optional object */ broadcast(message: unknown, options?: Nes.BroadcastOptions): void; /** * Declares a subscription path client can * subscribe to where: * * https://hapi.dev/module/nes/api/?v=13.0.1#serversubscriptionpath-options * * @param path An HTTP-like path. The path * must begin with the `'/'` character. The * path may contain path parameters as * supported by the ***hapi*** route path parser. * @param options An optional object */ subscription(path: string, options?: Nes.SubscriptionOptions): void; /** * Sends a message to all the subscribed clients * * https://github.com/hapijs/nes/blob/master/API.md#await-serverpublishpath-message-options * * @param path the subscription path. The path is matched * first against the available subscriptions added via * `server.subscription()` and then against the specific * path provided by each client at the time of * registration (only matter when the subscription path * contains parameters). When a match is found, the * subscription `filter` function is called (if present) * to further filter which client should receive which * update. * * @param message The message sent to the clients. Can be any type which can be safely converted to string using `JSON.stringify()`. * @param options optional object */ publish(path: string, message: unknown, options?: Nes.PublishOptions): void; /** * Iterates over all connected sockets, optionally * filtering on those that have subscribed to a given * subscription. This operation is synchronous * * @param each Iteration method * @param options Optional options */ eachSocket( each: (socket: Nes.Socket) => void, options?: Nes.EachSocketOptions ): void; } interface Request { /** * Provides access to the `Socket` object of the incoming * connection */ socket: Nes.Socket; } } ================================================ FILE: lib/index.js ================================================ 'use strict'; const Cryptiles = require('@hapi/cryptiles'); const Hoek = require('@hapi/hoek'); const Iron = require('@hapi/iron'); const Validate = require('@hapi/validate'); const Ws = require('ws'); const Client = require('./client'); const Listener = require('./listener'); const internals = { defaults: { auth: { endpoint: '/nes/auth', id: 'nes.auth', type: 'direct', cookie: 'nes', isSecure: true, isHttpOnly: true, isSameSite: 'Strict', path: '/', index: false, timeout: 5000, // 5 seconds maxConnectionsPerUser: false }, headers: null, payload: { maxChunkChars: false }, heartbeat: { interval: 15000, // 15 seconds timeout: 5000 // 5 seconds }, maxConnections: false } }; internals.schema = Validate.object({ onConnection: Validate.function(), // async function (socket) {} onDisconnection: Validate.function(), // function (socket) {} onMessage: Validate.function(), // async function (socket, message) { return data; } // Or throw errors auth: Validate.object({ endpoint: Validate.string().required(), id: Validate.string(), type: Validate.valid('cookie', 'token', 'direct').required(), route: [ Validate.object(), Validate.string() ], cookie: Validate.string().required(), isSecure: Validate.boolean(), isHttpOnly: Validate.boolean(), isSameSite: Validate.valid('Strict', 'Lax').allow(false), path: Validate.string().allow(null), domain: Validate.string().allow(null), ttl: Validate.number().allow(null), iron: Validate.object(), password: Validate.alternatives([ Validate.string(), Validate.binary(), Validate.object() ]), index: Validate.boolean(), timeout: Validate.number().integer().min(1).allow(false), maxConnectionsPerUser: Validate.number().integer().min(1).allow(false).when('index', { is: true, otherwise: Validate.valid(false) }), minAuthVerifyInterval: Validate.number().integer().allow(false).when('...heartbeat', { is: false, then: Validate.number().min(1), otherwise: Validate.number().min(Validate.ref('...heartbeat.interval')) }) }) .allow(false) .required(), headers: Validate.array().items(Validate.string().lowercase()).min(1).allow('*', null), payload: { maxChunkChars: Validate.number().integer().min(1).allow(false) }, heartbeat: Validate.object({ interval: Validate.number().integer().min(1).required(), timeout: Validate.number().integer().min(1).less(Validate.ref('interval')).required() }) .allow(false), maxConnections: Validate.number().integer().min(1).allow(false), origin: Validate.array().items(Validate.string()).single().min(1) }); exports.plugin = { pkg: require('../package.json'), requirements: { hapi: '>=19.0.0' }, register: function (server, options) { const settings = Hoek.applyToDefaults(internals.defaults, options); if (Array.isArray(settings.headers)) { settings.headers = settings.headers.map((field) => field.toLowerCase()); } if (settings.auth && settings.auth.minAuthVerifyInterval === undefined) { settings.auth.minAuthVerifyInterval = (settings.heartbeat ? settings.heartbeat.interval : internals.defaults.heartbeat.interval); } Validate.assert(settings, internals.schema, 'Invalid nes configuration'); // Authentication endpoint internals.auth(server, settings); // Create a listener per connection const listener = new Listener(server, settings); server.ext('onPreStart', () => { // Start heartbeats listener._beat(); // Clear stopped state if restarted listener._stopped = false; }); // Stop connections when server stops server.ext('onPreStop', () => listener._close()); // Decorate server and request server.decorate('server', 'broadcast', Listener.broadcast); server.decorate('server', 'subscription', Listener.subscription); server.decorate('server', 'publish', Listener.publish); server.decorate('server', 'eachSocket', Listener.eachSocket); server.decorate('request', 'socket', internals.socket, { apply: true }); } }; Client.Client.WebSocket = Ws; exports.Client = Client.Client; internals.auth = function (server, settings) { const config = settings.auth; if (!config) { return; } if (config.type !== 'direct' && !config.password) { config.password = Cryptiles.randomString(32); } if (config.type === 'cookie') { const cookieOptions = { isSecure: config.isSecure, isHttpOnly: config.isHttpOnly, isSameSite: config.isSameSite, path: config.path, domain: config.domain, ttl: config.ttl, encoding: 'iron', password: config.password, iron: config.iron }; server.state(config.cookie, cookieOptions); } server.route({ method: config.type === 'direct' ? 'auth' : 'GET', path: config.endpoint, config: { id: config.id, isInternal: config.type === 'direct', auth: config.route, handler: async (request, h) => { if (!request.auth.isAuthenticated) { return { status: 'unauthenticated' }; } const credentials = { credentials: request.auth.credentials, artifacts: request.auth.artifacts, strategy: request.auth.strategy }; if (config.type === 'direct') { return credentials; } const result = { status: 'authenticated' }; if (config.type === 'cookie') { return h.response(result).state(config.cookie, credentials); } const sealed = await Iron.seal(credentials, config.password, config.iron ?? Iron.defaults); result.token = sealed; return result; } } }); }; internals.socket = function (request) { return request.plugins.nes ? request.plugins.nes.socket : null; }; ================================================ FILE: lib/listener.js ================================================ 'use strict'; const Boom = require('@hapi/boom'); const Bounce = require('@hapi/bounce'); const Call = require('@hapi/call'); const Hoek = require('@hapi/hoek'); const Validate = require('@hapi/validate'); const Ws = require('ws'); const Socket = require('./socket'); const internals = { counter: { min: 10000, max: 99999 } }; exports = module.exports = internals.Listener = function (server, settings) { this._server = server; this._settings = settings; this._sockets = new internals.Sockets(this); this._router = new Call.Router(); this._authRoute = this._settings.auth && this._server.lookup(this._settings.auth.id); this._socketCounter = internals.counter.min; this._heartbeat = null; this._beatTimeout = null; this._stopped = false; // WebSocket listener const options = { server: this._server.listener }; if (settings.origin) { options.verifyClient = (info) => settings.origin.indexOf(info.origin) >= 0; } this._wss = new Ws.Server(options); this._wss.on('connection', (ws, req) => { ws.on('error', Hoek.ignore); if (this._stopped || (this._settings.maxConnections && this._sockets.length() >= this._settings.maxConnections)) { return ws.close(); } this._add(ws, req); }); this._wss.on('error', Hoek.ignore); // Register with the server this._server.plugins.nes = { _listener: this }; }; internals.Listener.prototype._add = function (ws, req) { // Socket object const socket = new Socket(ws, req, this); // Subscriptions this._sockets.add(socket); ws.once('close', async (code, message) => { this._sockets.remove(socket); clearTimeout(socket.auth._initialAuthTimeout); socket.auth._initialAuthTimeout = null; const subs = Object.keys(socket._subscriptions); for (let i = 0; i < subs.length; ++i) { const sub = subs[i]; const subscribers = socket._subscriptions[sub]; await subscribers.remove(socket); } socket._subscriptions = {}; if (this._settings.onDisconnection) { this._settings.onDisconnection(socket); } socket._removed.attend(); }); }; internals.Listener.prototype._close = async function () { this._stopped = true; clearTimeout(this._heartbeat); clearTimeout(this._beatTimeout); await Promise.all(Object.keys(this._sockets._items).map((id) => this._sockets._items[id].disconnect())); this._wss.close(); for (const ws of this._wss.clients) { ws.terminate(); } }; internals.Listener.prototype._authRequired = function () { if (!this._authRoute) { return false; } const auth = this._server.auth.lookup(this._authRoute); if (!auth) { return false; } return auth.mode === 'required'; }; internals.Listener.prototype._beat = function () { if (!this._settings.heartbeat) { return; } if (this._heartbeat && // Skip the first time this._sockets.length()) { // Send heartbeats const update = { type: 'ping' }; this._sockets._forEach((socket) => socket._send(update).catch(Hoek.ignore)); // Ignore errors // Verify client responded this._beatTimeout = setTimeout(() => { this._sockets._forEach((socket) => { if (!socket._active()) { socket.disconnect(); return; } socket._pinged = false; }); }, this._settings.heartbeat.timeout); } // Schedule next heartbeat this._heartbeat = setTimeout(() => { this._beat(); }, this._settings.heartbeat.interval); }; internals.Listener.broadcast = function (message, options) { options = options ?? {}; const update = { type: 'update', message }; return this.plugins.nes._listener._broadcast(update, options); }; internals.Listener.prototype._broadcast = function (update, options) { Hoek.assert(!options.user || (this._settings.auth && this._settings.auth.index), 'Socket auth indexing is disabled'); if (this._stopped) { return; } const each = (socket) => socket._send(update).catch(Hoek.ignore); // Ignore errors if (options.user) { const sockets = this._sockets._byUser[options.user]; if (!sockets) { return; } return sockets.forEach(each); } return this._sockets._forEach(each); }; internals.subSchema = Validate.object({ filter: Validate.func(), // async function (path, update, options), where options: { credentials, params }, returns true, false, { override }, or throws an error onSubscribe: Validate.func(), // async function (socket, path, params) onUnsubscribe: Validate.func(), // async function (socket, path, params) auth: Validate.object({ mode: Validate.string().valid('required', 'optional'), scope: Validate.array().items(Validate.string()).single().min(1), entity: Validate.valid('user', 'app', 'any'), index: Validate.boolean() }) .allow(false) }); internals.Listener.subscription = function (path, options) { Hoek.assert(path, 'Subscription missing path'); Validate.assert(options, internals.subSchema, 'Invalid subscription options: ' + path); const settings = Hoek.clone(options ?? {}); // Auth configuration const auth = settings.auth; if (auth) { if (auth.scope) { if (typeof auth.scope === 'string') { auth.scope = [auth.scope]; } for (let i = 0; i < auth.scope.length; ++i) { if (/{([^}]+)}/.test(auth.scope[i])) { auth.hasScopeParameters = true; break; } } } auth.mode = auth.mode || 'required'; } // Path configuration const route = { method: 'sub', path }; const config = { subscribers: new internals.Subscribers(this.plugins.nes._listener._server, settings), filter: settings.filter, auth }; this.plugins.nes._listener._router.add(route, config); }; internals.Listener.publish = function (path, update, options) { Hoek.assert(path && path[0] === '/', 'Missing or invalid subscription path:', path || 'empty'); options = options ?? {}; const message = { type: 'pub', path, message: update }; return this.plugins.nes._listener._publish(path, message, options); }; internals.Listener.prototype._publish = function (path, _update, options) { if (this._stopped) { return; } const match = this._router.route('sub', path); if (match.isBoom) { return; } const each = async (socket) => { // Filter on path if has parameters let update = _update; if (route.filter) { try { var isMatch = await route.filter(path, update.message, { socket, credentials: socket.auth.credentials, params: match.params, internal: options.internal }); } catch (err) { Bounce.rethrow(err, 'system'); } if (!isMatch) { return; } if (isMatch.override) { update = Object.assign({}, update); // Shallow cloned update.message = isMatch.override; } } return socket._send(update).catch(Hoek.ignore); // Ignore errors }; const route = match.route; return route.subscribers._forEachSubscriber(match.paramsArray.length ? path : null, options, each); }; internals.Listener.prototype._subscribe = async function (path, socket) { // Errors include subscription context in messages in case returned as connection errors if (path.indexOf('?') !== -1) { throw Boom.badRequest('Subscription path cannot contain query'); } if (socket._subscriptions[path]) { return; } const match = this._router.route('sub', path); if (match.isBoom) { throw Boom.notFound('Subscription not found'); } const auth = this._server.auth.lookup({ settings: { auth: match.route.auth } }); // Create a synthetic route if (auth) { const credentials = socket.auth.credentials; if (credentials) { // Check scope if (auth.scope) { let scopes = auth.scope; if (auth.hasScopeParameters) { scopes = []; const context = { params: match.params }; for (let i = 0; i < auth.scope.length; ++i) { scopes[i] = Hoek.reachTemplate(context, auth.scope[i]); } } if (!credentials.scope || (typeof credentials.scope === 'string' ? !scopes.includes(credentials.scope) : !Hoek.intersect(scopes, credentials.scope).length)) { throw Boom.forbidden('Insufficient scope to subscribe, expected any of: ' + scopes); } } // Check entity const entity = auth.entity || 'any'; if (entity === 'user' && !credentials.user) { throw Boom.forbidden('Application credentials cannot be used on a user subscription'); } if (entity === 'app' && credentials.user) { throw Boom.forbidden('User credentials cannot be used on an application subscription'); } } else if (auth.mode === 'required') { throw Boom.unauthorized('Authentication required to subscribe'); } } await match.route.subscribers.add(socket, path, match); socket._subscriptions[path] = match.route.subscribers; }; internals.Listener.eachSocket = function (each, options) { options = options ?? {}; return this.plugins.nes._listener._eachSocket(each, options); }; internals.Listener.prototype._eachSocket = function (each, options) { if (this._stopped) { return; } if (!options.subscription) { Hoek.assert(!options.user, 'Cannot specify user filter without a subscription path'); return this._sockets._forEach(each); } const sub = this._router.route('sub', options.subscription); if (sub.isBoom) { return; } const route = sub.route; return route.subscribers._forEachSubscriber(sub.paramsArray.length ? options.subscription : null, options, each); // Filter on path if has parameters }; internals.Listener.prototype._generateId = function () { const id = Date.now() + ':' + this._server.info.id + ':' + this._socketCounter++; if (this._socketCounter > internals.counter.max) { this._socketCounter = internals.counter.min; } return id; }; // Sockets manager internals.Sockets = function (listener) { this._listener = listener; this._items = {}; this._byUser = {}; // user -> [sockets] }; internals.Sockets.prototype.add = function (socket) { this._items[socket.id] = socket; }; internals.Sockets.prototype.auth = function (socket) { if (!this._listener._settings.auth.index) { return; } if (!socket.auth.credentials.user) { return; } const user = socket.auth.credentials.user; if (this._listener._settings.auth.maxConnectionsPerUser && this._byUser[user] && this._byUser[user].length >= this._listener._settings.auth.maxConnectionsPerUser) { throw Boom.serverUnavailable('Too many connections for the authenticated user'); } this._byUser[user] = this._byUser[user] ?? []; this._byUser[user].push(socket); }; internals.Sockets.prototype.remove = function (socket) { delete this._items[socket.id]; if (socket.auth.credentials && socket.auth.credentials.user) { const user = socket.auth.credentials.user; if (this._byUser[user]) { this._byUser[user] = this._byUser[user].filter((item) => item !== socket); if (!this._byUser[user].length) { delete this._byUser[user]; } } } }; internals.Sockets.prototype._forEach = async function (each) { for (const item in this._items) { await each(this._items[item]); } }; internals.Sockets.prototype.length = function () { return Object.keys(this._items).length; }; // Subscribers manager internals.Subscribers = function (server, options) { this._server = server; this._settings = options; this._items = {}; this._byUser = {}; // user -> [item] }; internals.Subscribers.prototype.add = async function (socket, path, match) { if (this._settings.onSubscribe) { await this._settings.onSubscribe(socket, path, match.params); } const item = this._items[socket.id]; if (item) { item.paths.push(path); item.params.push(match.params); } else { this._items[socket.id] = { socket, paths: [path], params: [match.params] }; if (this._settings.auth && this._settings.auth.index && socket.auth.credentials && socket.auth.credentials.user) { const user = socket.auth.credentials.user; this._byUser[user] = this._byUser[user] ?? []; this._byUser[user].push(this._items[socket.id]); } } }; internals.Subscribers.prototype.remove = async function (socket, path) { const item = this._items[socket.id]; if (!item) { return; } if (!path) { this._cleanup(socket, item); if (this._settings.onUnsubscribe) { for (let i = 0; i < item.paths.length; ++i) { const itemPath = item.paths[i]; await this._remove(socket, itemPath, item.params[i]); } } return; } const pos = item.paths.indexOf(path); const params = item.params[pos]; if (item.paths.length === 1) { this._cleanup(socket, item); } else { item.paths.splice(pos, 1); item.params.splice(pos, 1); } if (this._settings.onUnsubscribe) { return this._remove(socket, path, params); } }; internals.Subscribers.prototype._remove = async function (socket, path, params) { try { await this._settings.onUnsubscribe(socket, path, params); } catch (err) { this._server.log(['nes', 'onUnsubscribe', 'error'], err); } }; internals.Subscribers.prototype._cleanup = function (socket, item) { delete this._items[socket.id]; if (socket.auth.credentials && socket.auth.credentials.user && this._byUser[socket.auth.credentials.user]) { const user = socket.auth.credentials.user; this._byUser[user] = this._byUser[user].filter((record) => record !== item); if (!this._byUser[user].length) { delete this._byUser[user]; } } }; internals.Subscribers.prototype._forEachSubscriber = async function (path, options, each) { const itemize = async (item) => { if (item && // check item not removed (!path || item.paths.indexOf(path) !== -1)) { await each(item.socket); } }; if (options.user) { Hoek.assert(this._settings.auth && this._settings.auth.index, 'Subscription auth indexing is disabled'); const items = this._byUser[options.user]; if (items) { for (let i = 0; i < items.length; ++i) { const item = items[i]; await itemize(item); } } } else { const items = Object.keys(this._items); for (let i = 0; i < items.length; ++i) { const item = this._items[items[i]]; await itemize(item); } } }; ================================================ FILE: lib/socket.js ================================================ 'use strict'; const Boom = require('@hapi/boom'); const Bounce = require('@hapi/bounce'); const Cryptiles = require('@hapi/cryptiles'); const Hoek = require('@hapi/hoek'); const Iron = require('@hapi/iron'); const Teamwork = require('@hapi/teamwork'); const internals = { version: '2' }; exports = module.exports = internals.Socket = function (ws, req, listener) { this._ws = ws; this._cookies = req.headers.cookie; this._listener = listener; this._helloed = false; this._pinged = true; this._processingCount = 0; this._subscriptions = {}; this._packets = []; this._sending = false; this._removed = new Teamwork.Team(); this.server = this._listener._server; this.id = this._listener._generateId(); this.app = {}; this.auth = { isAuthenticated: false, credentials: null, artifacts: null, _error: null, _initialAuthTimeout: null, _verified: 0 }; this.info = { remoteAddress: req.socket.remoteAddress, remotePort: req.socket.remotePort, 'x-forwarded-for': req.headers['x-forwarded-for'] }; if (this._listener._settings.auth && this._listener._settings.auth.timeout) { this.auth._initialAuthTimeout = setTimeout(() => this.disconnect(), this._listener._settings.auth.timeout); } this._ws.on('message', (message) => this._onMessage(message)); }; internals.Socket.prototype.disconnect = function () { clearTimeout(this.auth._initialAuthTimeout); this.auth._initialAuthTimeout = null; this._ws.close(); return this._removed; }; internals.Socket.prototype.send = function (message) { const response = { type: 'update', message }; return this._send(response); }; internals.Socket.prototype.publish = function (path, update) { const message = { type: 'pub', path, message: update }; return this._send(message); }; internals.Socket.prototype.revoke = async function (path, update, options = {}) { await this._unsubscribe(path); const message = { type: 'revoke', path }; if (update !== null) { // Allow sending falsy values message.message = update; } if (options.ignoreClose && !this.isOpen()) { return Promise.resolve(); } return this._send(message); }; internals.Socket.prototype.isOpen = function () { return this._ws.readyState === 1; }; internals.Socket.prototype._send = function (message, options) { options = options ?? {}; if (!this.isOpen()) { // Open return Promise.reject(Boom.internal('Socket not open')); } try { var string = JSON.stringify(message); if (options.replace) { Object.keys(options.replace).forEach((token) => { string = string.replace(`"${token}"`, options.replace[token]); }); } } catch (err) { this.server.log(['nes', 'serialization', 'error'], message.type); if (message.id) { return this._error(Boom.internal('Failed serializing message'), message); } return Promise.reject(err); } const team = new Teamwork.Team(); this._packets.push({ message: string, type: message.type, team }); this._flush(); return team.work; }; internals.Socket.prototype._flush = async function () { if (this._sending || !this._packets.length) { return; } this._sending = true; const packet = this._packets.shift(); let messages = [packet.message]; // Break message into smaller chunks const maxChunkChars = this._listener._settings.payload.maxChunkChars; if (maxChunkChars && packet.message.length > maxChunkChars) { messages = []; const parts = Math.ceil(packet.message.length / maxChunkChars); for (let i = 0; i < parts; ++i) { const last = (i === parts - 1); const prefix = (last ? '!' : '+'); messages.push(prefix + packet.message.slice(i * maxChunkChars, (i + 1) * maxChunkChars)); } } for (let i = 0; i < messages.length; ++i) { const message = messages[i]; const team = new Teamwork.Team(); this._ws.send(message, (err) => team.attend(err)); try { await team.work; } catch (err) { var error = err; break; } if (packet.type !== 'ping') { this._pinged = true; // Consider the connection valid if send() was successful } } this._sending = false; packet.team.attend(error); setImmediate(() => this._flush()); }; internals.Socket.prototype._active = function () { return this._pinged || this._sending || this._processingCount; }; internals.Socket.prototype._error = function (err, request) { err = Boom.boomify(err); const message = Hoek.clone(err.output); delete message.payload.statusCode; message.headers = this._filterHeaders(message.headers); if (request) { message.type = request.type; message.id = request.id; } if (err.path) { message.path = err.path; } return this._send(message); }; internals.Socket.prototype._onMessage = async function (message) { try { var request = JSON.parse(message); } catch { return this._error(Boom.badRequest('Cannot parse message')); } this._pinged = true; ++this._processingCount; try { var { response, options } = await this._lifecycle(request); } catch (err) { Bounce.rethrow(err, 'system'); var error = err; } try { if (error) { await this._error(error, request); } else if (response) { await this._send(response, options); } } catch (err) { Bounce.rethrow(err, 'system'); this.disconnect(); } --this._processingCount; }; internals.Socket.prototype._lifecycle = async function (request) { if (!request.type) { throw Boom.badRequest('Cannot parse message'); } if (!request.id) { throw Boom.badRequest('Message missing id'); } await this._verifyAuth(); // Initialization and Authentication if (request.type === 'ping') { return {}; } if (request.type === 'hello') { return this._processHello(request); } if (!this._helloed) { throw Boom.badRequest('Connection is not initialized'); } if (request.type === 'reauth') { return this._processReauth(request); } // Endpoint request if (request.type === 'request') { return this._processRequest(request); } // Custom message request if (request.type === 'message') { return this._processMessage(request); } // Subscriptions if (request.type === 'sub') { return this._processSubscription(request); } if (request.type === 'unsub') { return this._processUnsub(request); } // Unknown throw Boom.badRequest('Unknown message type'); }; internals.Socket.prototype._processHello = async function (request) { if (this._helloed) { throw Boom.badRequest('Connection already initialized'); } if (request.version !== internals.version) { throw Boom.badRequest('Incorrect protocol version (expected ' + internals.version + ' but received ' + (request.version || 'none') + ')'); } this._helloed = true; // Prevents the client from reusing the socket if erred (leaves socket open to ensure client gets the error response) await this._authenticate(request); if (this._listener._settings.onConnection) { await this._listener._settings.onConnection(this); } if (request.subs) { for (let i = 0; i < request.subs.length; ++i) { const path = request.subs[i]; try { await this._listener._subscribe(path, this); } catch (err) { err.path = path; throw err; } } } const response = { type: 'hello', id: request.id, heartbeat: this._listener._settings.heartbeat, socket: this.id }; return { response }; }; internals.Socket.prototype._processReauth = async function (request) { try { await this._authenticate(request); } catch (err) { Bounce.rethrow(err, 'system'); setTimeout(() => this.disconnect()); // disconnect after sending the response to the client throw err; } const response = { type: 'reauth', id: request.id }; return { response }; }; internals.Socket.prototype._processRequest = async function (request) { let method = request.method; if (!method) { throw Boom.badRequest('Message missing method'); } let path = request.path; if (!path) { throw Boom.badRequest('Message missing path'); } if (request.headers && internals.caseInsensitiveKey(request.headers, 'authorization')) { throw Boom.badRequest('Cannot include an Authorization header'); } if (path[0] !== '/') { // Route id const route = this.server.lookup(path); if (!route) { throw Boom.notFound(); } path = route.path; method = route.method; if (method === '*') { throw Boom.badRequest('Cannot use route id with wildcard method route config'); } } if (this._listener._settings.auth && path === this._listener._settings.auth.endpoint) { throw Boom.notFound(); } const shot = { method, url: path, payload: request.payload, headers: request.headers, auth: this.auth.isAuthenticated ? this.auth : null, validate: false, plugins: { nes: { socket: this } }, remoteAddress: this.info.remoteAddress }; const res = await this.server.inject(shot); const response = { type: 'request', id: request.id, statusCode: res.statusCode, payload: res.result, headers: this._filterHeaders(res.headers) }; const options = {}; if (typeof res.result === 'string' && res.headers['content-type'] && res.headers['content-type'].match(/^application\/json/)) { const token = Cryptiles.randomString(32); options.replace = { [token]: res.result }; response.payload = token; } return { response, options }; }; internals.Socket.prototype._processMessage = async function (request) { if (!this._listener._settings.onMessage) { throw Boom.notImplemented(); } const message = await this._listener._settings.onMessage(this, request.message); const response = { type: 'message', id: request.id, message }; return { response }; }; internals.Socket.prototype._processSubscription = async function (request) { await this._listener._subscribe(request.path, this); const response = { type: 'sub', id: request.id, path: request.path }; return { response }; }; internals.Socket.prototype._processUnsub = async function (request) { await this._unsubscribe(request.path); const response = { type: 'unsub', id: request.id }; return { response }; }; internals.Socket.prototype._unsubscribe = function (path) { const sub = this._subscriptions[path]; if (sub) { delete this._subscriptions[path]; return sub.remove(this, path); } }; internals.Socket.prototype._authenticate = async function (request) { await this._authByCookie(); if (!request.auth && !this.auth.isAuthenticated && this._listener._authRequired()) { throw Boom.unauthorized('Connection requires authentication'); } if (request.auth && request.type !== 'reauth' && this.auth.isAuthenticated) { // Authenticated using a cookie during upgrade throw Boom.badRequest('Connection already authenticated'); } clearTimeout(this.auth._initialAuthTimeout); this.auth._initialAuthTimeout = null; if (request.auth) { const config = this._listener._settings.auth; if (config.type === 'direct') { const route = this.server.lookup(config.id); request.auth.headers = request.auth.headers ?? {}; request.auth.headers['x-forwarded-for'] = this.info['x-forwarded-for']; const res = await this.server.inject({ url: route.path, method: 'auth', headers: request.auth.headers, remoteAddress: this.info.remoteAddress, allowInternals: true, validate: false }); if (res.statusCode !== 200) { throw Boom.unauthorized(res.result.message); } if (!res.result.credentials) { return; } this._setCredentials(res.result); return; } try { const auth = await Iron.unseal(request.auth, config.password, config.iron ?? Iron.defaults); this._setCredentials(auth); } catch { throw Boom.unauthorized('Invalid token'); } } }; internals.Socket.prototype._authByCookie = async function () { const config = this._listener._settings.auth; if (!config) { return; } const cookies = this._cookies; if (!cookies) { return; } try { var { states } = await this.server.states.parse(cookies); } catch { throw Boom.unauthorized('Invalid nes authentication cookie'); } const auth = states[config.cookie]; if (auth) { this._setCredentials(auth); } }; internals.Socket.prototype._setCredentials = function (auth) { this.auth.isAuthenticated = true; this.auth.credentials = auth.credentials; this.auth.artifacts = auth.artifacts; this.auth.strategy = auth.strategy; this.auth._verified = Date.now(); this._listener._sockets.auth(this); }; internals.Socket.prototype._verifyAuth = async function () { const now = Date.now(); if (!this._listener._settings.auth.minAuthVerifyInterval || now - this.auth._verified < this._listener._settings.auth.minAuthVerifyInterval) { return; } this.auth._verified = now; try { await this.server.auth.verify(this); } catch (err) { Bounce.rethrow(err, 'system'); setImmediate(() => this.disconnect()); throw Boom.forbidden('Credential verification failed'); } }; internals.Socket.prototype._filterHeaders = function (headers) { const filter = this._listener._settings.headers; if (!filter) { return undefined; } if (filter === '*') { return headers; } const filtered = {}; const fields = Object.keys(headers); for (let i = 0; i < fields.length; ++i) { const field = fields[i]; if (filter.indexOf(field.toLowerCase()) !== -1) { filtered[field] = headers[field]; } } return filtered; }; internals.caseInsensitiveKey = function (object, key) { const keys = Object.keys(object); for (let i = 0; i < keys.length; ++i) { const current = keys[i]; if (key === current.toLowerCase()) { return object[current]; } } return undefined; }; ================================================ FILE: package.json ================================================ { "name": "@hapi/nes", "description": "WebSocket adapter plugin for hapi routes", "version": "14.0.1", "repository": "git://github.com/hapijs/nes", "main": "lib/index.js", "types": "lib/index.d.ts", "engines": { "node": ">=18.0.0" }, "files": [ "lib" ], "keywords": [ "hapi", "plugin", "websocket" ], "dependencies": { "@hapi/boom": "^10.0.1", "@hapi/bounce": "^3.0.2", "@hapi/call": "^9.0.1", "@hapi/cryptiles": "^6.0.1", "@hapi/hoek": "^11.0.7", "@hapi/iron": "^7.0.1", "@hapi/teamwork": "^6.0.0", "@hapi/validate": "^2.0.1", "@types/ws": "^8.5.13", "ws": "^8.18.0" }, "devDependencies": { "@hapi/code": "^9.0.3", "@hapi/eslint-plugin": "^7.0.0", "@hapi/hapi": "^21.3.12", "@hapi/lab": "^26.0.0", "@types/node": "^20.17.12", "joi": "^17.13.3", "typescript": "^5.4.5" }, "scripts": { "test": "lab -a @hapi/code -t 100 -L -m 10000 -Y", "test-cov-html": "lab -a @hapi/code -r html -o coverage.html -m 10000" }, "license": "BSD-3-Clause" } ================================================ FILE: test/auth.js ================================================ 'use strict'; const Url = require('url'); const Boom = require('@hapi/boom'); const Code = require('@hapi/code'); const Hapi = require('@hapi/hapi'); const Hoek = require('@hapi/hoek'); const Iron = require('@hapi/iron'); const Lab = require('@hapi/lab'); const Nes = require('../'); const Teamwork = require('@hapi/teamwork'); const internals = {}; const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; describe('authentication', () => { const password = 'some_not_random_password_that_is_also_long_enough'; const getUri = ({ protocol, address, port }) => Url.format({ protocol, hostname: address, port }); it('times out when hello is delayed', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { timeout: 100 } } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); client._hello = () => Promise.resolve(); client.onError = Hoek.ignore; const team = new Teamwork.Team(); client.onDisconnect = () => team.attend(); await client.connect({ auth: { headers: { authorization: 'Custom john' } } }); await team.work; await server.stop(); }); it('disables timeout when hello is delayed', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { timeout: false } } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); client._hello = () => Promise.resolve(); client.onError = Hoek.ignore; const connecting = client.connect({ auth: { headers: { authorization: 'Custom john' } } }); await Hoek.wait(100); await server.stop(); await connecting; }); describe('cookie', () => { it('protects an endpoint', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: { type: 'cookie' } } }); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const res = await server.inject({ url: '/nes/auth', headers: { authorization: 'Custom john' } }); expect(res.result.status).to.equal('authenticated'); const header = res.headers['set-cookie'][0]; const cookie = header.match(/(?:[^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)\s*=\s*(?:([^\x00-\x20\"\,\;\\\x7F]*))/); const client = new Nes.Client(getUri(server.info), { ws: { headers: { cookie: 'nes=' + cookie[1] } } }); await client.connect(); const { payload, statusCode } = await client.request('/'); expect(payload).to.equal('hello'); expect(statusCode).to.equal(200); client.disconnect(); await server.stop(); }); it('limits connections per user (single)', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: { type: 'cookie', maxConnectionsPerUser: 1, index: true } } }); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const res = await server.inject({ url: '/nes/auth', headers: { authorization: 'Custom john' } }); expect(res.result.status).to.equal('authenticated'); const header = res.headers['set-cookie'][0]; const cookie = header.match(/(?:[^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)\s*=\s*(?:([^\x00-\x20\"\,\;\\\x7F]*))/); const client = new Nes.Client(getUri(server.info), { ws: { headers: { cookie: 'nes=' + cookie[1] } } }); await client.connect(); const client2 = new Nes.Client(getUri(server.info), { ws: { headers: { cookie: 'nes=' + cookie[1] } } }); await expect(client2.connect()).to.reject('Too many connections for the authenticated user'); client.disconnect(); client2.disconnect(); await server.stop(); }); it('limits connections per user', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: { type: 'cookie', maxConnectionsPerUser: 2, index: true } } }); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const res = await server.inject({ url: '/nes/auth', headers: { authorization: 'Custom john' } }); expect(res.result.status).to.equal('authenticated'); const header = res.headers['set-cookie'][0]; const cookie = header.match(/(?:[^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)\s*=\s*(?:([^\x00-\x20\"\,\;\\\x7F]*))/); const client = new Nes.Client(getUri(server.info), { ws: { headers: { cookie: 'nes=' + cookie[1] } } }); await client.connect(); const client2 = new Nes.Client(getUri(server.info), { ws: { headers: { cookie: 'nes=' + cookie[1] } } }); await client2.connect(); const client3 = new Nes.Client(getUri(server.info), { ws: { headers: { cookie: 'nes=' + cookie[1] } } }); await expect(client3.connect()).to.reject('Too many connections for the authenticated user'); client.disconnect(); client2.disconnect(); client3.disconnect(); await server.stop(); }); it('protects an endpoint (no default auth)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); await server.register({ plugin: Nes, options: { auth: { type: 'cookie', route: 'default' } } }); server.route({ method: 'GET', path: '/', config: { auth: 'default', handler: () => 'hello' } }); await server.start(); const res = await server.inject({ url: '/nes/auth', headers: { authorization: 'Custom john' } }); expect(res.result.status).to.equal('authenticated'); const header = res.headers['set-cookie'][0]; const cookie = header.match(/(?:[^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)\s*=\s*(?:([^\x00-\x20\"\,\;\\\x7F]*))/); const client = new Nes.Client(getUri(server.info), { ws: { headers: { cookie: 'nes=' + cookie[1] } } }); await client.connect(); const { payload, statusCode } = await client.request('/'); expect(payload).to.equal('hello'); expect(statusCode).to.equal(200); client.disconnect(); await server.stop(); }); it('errors on missing auth on an authentication endpoint', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'cookie', password, route: { mode: 'optional' } } } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const res = await server.inject('/nes/auth'); expect(res.result.status).to.equal('unauthenticated'); const client = new Nes.Client(getUri(server.info)); await client.connect(); const err = await expect(client.request('/')).to.reject('Missing authentication'); expect(err.statusCode).to.equal(401); client.disconnect(); await server.stop(); }); it('errors on missing auth on an authentication endpoint (other cookies)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'cookie', password, route: { mode: 'optional' } } } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const res = await server.inject('/nes/auth'); expect(res.result.status).to.equal('unauthenticated'); const client = new Nes.Client(getUri(server.info), { ws: { headers: { cookie: 'xnes=123' } } }); await client.connect(); const err = await expect(client.request('/')).to.reject('Missing authentication'); expect(err.statusCode).to.equal(401); client.disconnect(); await server.stop(); }); it('errors on double auth', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: { type: 'cookie' } } }); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const res = await server.inject({ url: '/nes/auth', headers: { authorization: 'Custom john' } }); expect(res.result.status).to.equal('authenticated'); const header = res.headers['set-cookie'][0]; const cookie = header.match(/(?:[^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)\s*=\s*(?:([^\x00-\x20\"\,\;\\\x7F]*))/); const client = new Nes.Client(getUri(server.info), { ws: { headers: { cookie: 'nes=' + cookie[1] } } }); const err = await expect(client.connect({ auth: 'something' })).to.reject('Connection already authenticated'); expect(err.statusCode).to.equal(400); client.disconnect(); await server.stop(); }); it('errors on invalid cookie', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: { type: 'cookie' } } }); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info), { ws: { headers: { cookie: '"' } } }); await expect(client.connect()).to.reject('Invalid nes authentication cookie'); client.disconnect(); await server.stop(); }); it('overrides cookie path', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'cookie', password, path: '/nes/xyz' } } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); const res = await server.inject({ url: '/nes/auth', headers: { authorization: 'Custom john' } }); expect(res.result.status).to.equal('authenticated'); const header = res.headers['set-cookie'][0]; expect(header).to.contain('Path=/nes/xyz'); }); }); describe('token', () => { it('protects an endpoint', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'token', password } } }); server.route({ method: 'GET', path: '/', handler: (request) => request.auth }); await server.start(); const res = await server.inject({ url: '/nes/auth', headers: { authorization: 'Custom john' } }); expect(res.result.status).to.equal('authenticated'); expect(res.result.token).to.exist(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: res.result.token }); const { payload, statusCode } = await client.request('/'); expect(payload).to.include({ isAuthenticated: true, credentials: { user: 'john', scope: 'a' }, artifacts: { userArtifact: 'abc', expires: payload.artifacts.expires } }); expect(statusCode).to.equal(200); client.disconnect(); await server.stop(); }); it('protects an endpoint (token with iron settings)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'token', password, iron: Iron.defaults } } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const res = await server.inject({ url: '/nes/auth', headers: { authorization: 'Custom john' } }); expect(res.result.status).to.equal('authenticated'); expect(res.result.token).to.exist(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: res.result.token }); const { payload, statusCode } = await client.request('/'); expect(payload).to.equal('hello'); expect(statusCode).to.equal(200); client.disconnect(); await server.stop(); }); it('errors on invalid token', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'token', password } } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); const err = await expect(client.connect({ auth: 'abc' })).to.reject('Invalid token'); expect(err.statusCode).to.equal(401); client.disconnect(); await server.stop(); }); it('errors on missing token', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'token', password } } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); const err = await expect(client.connect({ auth: '' })).to.reject('Connection requires authentication'); expect(err.statusCode).to.equal(401); client.disconnect(); await server.stop(); }); it('errors on invalid iron password', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'token', password: Buffer.from('') } } }); const res = await server.inject({ url: '/nes/auth', headers: { authorization: 'Custom john' } }); expect(res.statusCode).to.equal(500); }); it('errors on double authentication', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'token', password } } }); await server.start(); const res = await server.inject({ url: '/nes/auth', headers: { authorization: 'Custom john' } }); expect(res.result.status).to.equal('authenticated'); expect(res.result.token).to.exist(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: res.result.token }); const err = await expect(client._hello(res.result.token)).to.reject('Connection already initialized'); expect(err.statusCode).to.equal(400); client.disconnect(); await server.stop(); }); }); describe('direct', () => { it('protects an endpoint', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register(Nes); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom john' } } }); const { payload, statusCode } = await client.request('/'); expect(payload).to.equal('hello'); expect(statusCode).to.equal(200); client.disconnect(); await server.stop(); }); it('limits number of connections per user', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { index: true, maxConnectionsPerUser: 1 } } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom john' } } }); const client2 = new Nes.Client(getUri(server.info)); await expect(client2.connect({ auth: { headers: { authorization: 'Custom john' } } })).to.reject('Too many connections for the authenticated user'); client.disconnect(); client2.disconnect(); await server.stop(); }); it('protects an endpoint with prefix', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register(Nes, { routes: { prefix: '/foo' } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom john' } } }); const { payload, statusCode } = await client.request('/'); expect(payload).to.equal('hello'); expect(statusCode).to.equal(200); client.disconnect(); await server.stop(); }); it('reconnects automatically', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); let e = 0; client.onError = (err) => { expect(err).to.exist(); ++e; }; let c = 0; client.onConnect = () => ++c; expect(c).to.equal(0); expect(e).to.equal(0); await client.connect({ delay: 100, auth: { headers: { authorization: 'Custom john' } } }); expect(c).to.equal(1); expect(e).to.equal(0); client._ws.close(); await Hoek.wait(300); expect(c).to.equal(2); expect(e).to.equal(0); const { payload, statusCode } = await client.request('/'); expect(payload).to.equal('hello'); expect(statusCode).to.equal(200); client.disconnect(); await server.stop(); }); it('does not reconnect when auth fails', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); let c = 0; client.onConnect = () => ++c; expect(c).to.equal(0); await expect(client.connect({ delay: 10, auth: { headers: { authorization: 'Custom steve' } } })).to.reject(); expect(c).to.equal(0); await Hoek.wait(20); expect(c).to.equal(0); client.disconnect(); await server.stop(); }); it('fails authentication', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await expect(client.connect({ auth: { headers: { authorization: 'Custom steve' } } })).to.reject('Unknown user'); client.disconnect(); await server.stop(); }); it('fails authentication', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await expect(client.connect({ auth: '' })).to.reject('Connection requires authentication'); client.disconnect(); await server.stop(); }); it('fails authentication entity (app) with specified remoteAddress', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password, index: true } } }); server.subscription('/', { auth: { entity: 'app' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await expect(client.connect({ auth: { headers: { authorization: 'Custom app remoteAddress' } } })).to.reject('remoteAddress is not in whitelist'); client.disconnect(); await server.stop(); }); it('handles optional auth on auth endpoint', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password, route: { mode: 'optional' } } } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: {} }); const err = await expect(client.request('/')).to.reject('Missing authentication'); expect(err.statusCode).to.equal(401); client.disconnect(); await server.stop(); }); it('subscribes to a path', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/'); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom john' } } }); const team = new Teamwork.Team(); const handler = (update) => { expect(client.subscriptions()).to.equal(['/']); expect(update).to.equal('heya'); team.attend(); }; await client.subscribe('/', handler); server.publish('/', 'heya'); await team.work; client.disconnect(); await server.stop(); }); it('subscribes to a path with filter', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); const filter = (path, update, options) => { return (options.credentials.user === update); }; server.subscription('/', { filter }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom john' } } }); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal('john'); team.attend(); }; await client.subscribe('/', handler); server.publish('/', 'steve'); server.publish('/', 'john'); await team.work; client.disconnect(); await server.stop(); }); it('errors on missing auth to subscribe (config)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { mode: 'required' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await expect(client.subscribe('/', Hoek.ignore)).to.reject('Authentication required to subscribe'); expect(client.subscriptions()).to.equal([]); client.disconnect(); await server.stop(); }); it('does not require auth to subscribe without a default', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/'); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal('heya'); expect(client.subscriptions()).to.equal(['/']); team.attend(); }; await client.subscribe('/', handler); server.publish('/', 'heya'); await team.work; client.disconnect(); await server.stop(); }); it('does not require auth to subscribe with optional auth', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default({ strategy: 'default', mode: 'optional' }); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/'); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal('heya'); expect(client.subscriptions()).to.equal(['/']); team.attend(); }; await client.subscribe('/', handler); server.publish('/', 'heya'); await team.work; client.disconnect(); await server.stop(); }); it('matches entity (user)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password, index: true } } }); server.subscription('/', { auth: { entity: 'user' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom john' } } }); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal('heya'); expect(client.subscriptions()).to.equal(['/']); team.attend(); }; await client.subscribe('/', handler); server.publish('/', 'heya'); await team.work; client.disconnect(); await server.stop(); }); it('matches entity (app)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password, index: true } } }); server.subscription('/', { auth: { entity: 'app' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom app' } } }); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal('heya'); expect(client.subscriptions()).to.equal(['/']); team.attend(); }; await client.subscribe('/', handler); server.publish('/', 'heya'); await team.work; client.disconnect(); await server.stop(); }); it('errors on wrong entity (user)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { entity: 'app' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom john' } } }); await expect(client.subscribe('/', Hoek.ignore)).to.reject('User credentials cannot be used on an application subscription'); expect(client.subscriptions()).to.equal([]); client.disconnect(); await server.stop(); }); it('errors on wrong entity (app)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { entity: 'user' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom app' } } }); await expect(client.subscribe('/', Hoek.ignore)).to.reject('Application credentials cannot be used on a user subscription'); expect(client.subscriptions()).to.equal([]); client.disconnect(); await server.stop(); }); it('matches scope (string/string)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { scope: 'a' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom john' } } }); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal('heya'); expect(client.subscriptions()).to.equal(['/']); team.attend(); }; await client.subscribe('/', handler); server.publish('/', 'heya'); await team.work; client.disconnect(); await server.stop(); }); it('matches scope (array/string)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { scope: ['x', 'a'] } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom john' } } }); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal('heya'); expect(client.subscriptions()).to.equal(['/']); team.attend(); }; await client.subscribe('/', handler); server.publish('/', 'heya'); await team.work; client.disconnect(); await server.stop(); }); it('matches scope (string/array)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { scope: 'a' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom ed' } } }); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal('heya'); expect(client.subscriptions()).to.equal(['/']); team.attend(); }; await client.subscribe('/', handler); server.publish('/', 'heya'); await team.work; client.disconnect(); await server.stop(); }); it('matches scope (array/array)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { scope: ['b', 'a'] } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom ed' } } }); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal('heya'); expect(client.subscriptions()).to.equal(['/']); team.attend(); }; await client.subscribe('/', handler); server.publish('/', 'heya'); await team.work; client.disconnect(); await server.stop(); }); it('matches scope (dynamic)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/{id}', { auth: { scope: ['{params.id}'] } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom ed' } } }); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal('heya'); expect(client.subscriptions()).to.equal(['/5']); team.attend(); }; await client.subscribe('/5', handler); server.publish('/5', 'heya'); await team.work; client.disconnect(); await server.stop(); }); it('errors on wrong scope (string/string)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { scope: 'b' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom john' } } }); await expect(client.subscribe('/', Hoek.ignore)).to.reject('Insufficient scope to subscribe, expected any of: b'); expect(client.subscriptions()).to.equal([]); client.disconnect(); await server.stop(); }); it('errors on wrong scope (string/array)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { scope: 'x' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom ed' } } }); await expect(client.subscribe('/', Hoek.ignore)).to.reject('Insufficient scope to subscribe, expected any of: x'); expect(client.subscriptions()).to.equal([]); client.disconnect(); await server.stop(); }); it('errors on wrong scope (string/none)', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { scope: 'x' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ auth: { headers: { authorization: 'Custom app' } } }); await expect(client.subscribe('/', Hoek.ignore)).to.reject('Insufficient scope to subscribe, expected any of: x'); expect(client.subscriptions()).to.equal([]); client.disconnect(); await server.stop(); }); it('updates authentication information', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); server.route({ method: 'GET', path: '/', handler: (request) => request.auth.artifacts }); await server.register(Nes); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ reconnect: false, auth: { headers: { authorization: 'Custom john' } } }); const res1 = await client.request('/'); expect(res1.payload.userArtifact).to.equal('abc'); expect(res1.statusCode).to.equal(200); await client.reauthenticate({ headers: { authorization: 'Custom ed' } }); const res2 = await client.request('/'); expect(res2.payload.userArtifact).to.equal('xyz'); // updated artifacts expect(res2.statusCode).to.equal(200); await server.stop(); }); it('disconnects the client when re-authentication fails', async () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register(Nes); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ reconnect: false, auth: { headers: { authorization: 'Custom john' } } }); const team = new Teamwork.Team(); client.onDisconnect = () => { team.attend(); }; await expect(client.reauthenticate({ headers: { authorization: 'Custom no-such-user' } })).to.reject(); await team.work; await server.stop(); }); it('disconnects the client after authentication expires', async () => { const server = Hapi.server(); const scheme = internals.implementation({ authExpiry: 1100 }); server.auth.scheme('custom', () => scheme); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { minAuthVerifyInterval: 300 }, heartbeat: { interval: 200, timeout: 180 } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ reconnect: false, auth: { headers: { authorization: 'Custom john' } } }); const team = new Teamwork.Team(); client.onDisconnect = () => { team.attend(); }; await team.work; expect(scheme.verified).to.equal(['abc', 'abc', 'abc']); await server.stop(); }); it('disconnects the client after authentication expires (sets default check interval)', async () => { const server = Hapi.server(); const scheme = internals.implementation(); server.auth.scheme('custom', () => scheme); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { heartbeat: { interval: 100, timeout: 30 } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ reconnect: false, auth: { headers: { authorization: 'Custom john' } } }); const team = new Teamwork.Team(); client.onDisconnect = () => { team.attend(); }; await team.work; // there's 4 ping events, but only 3 verify checks - the first one is skipped, as it very close to 'hello' expect(scheme.verified.length).to.be.lessThan(4); await server.stop(); }); it('disconnects the client on request when authentication expires when heartbeat disabled', async () => { const server = Hapi.server(); const scheme = internals.implementation({ authExpiry: 300 }); server.auth.scheme('custom', () => scheme); server.auth.strategy('default', 'custom'); server.auth.default('default'); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.register({ plugin: Nes, options: { heartbeat: false, auth: { minAuthVerifyInterval: 100 } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ reconnect: false, auth: { headers: { authorization: 'Custom john' } } }); await Hoek.wait(301); const team = new Teamwork.Team(); client.onDisconnect = () => { team.attend(); }; await expect(client.request('/')).to.reject('Credential verification failed'); await team.work; // Only one message exchange between server/client, therefore a single verification expect(scheme.verified).to.equal(['abc']); await server.stop(); }); it('defaults minAuthVerifyInterval to hearbeat interval default when heartbeat disabled', async () => { const server = Hapi.server(); const scheme = internals.implementation(); server.auth.scheme('custom', () => scheme); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { heartbeat: false } }); expect(server.plugins.nes._listener._settings.auth.minAuthVerifyInterval).to.equal(15000); }); it('uses updated authentication information when verifying', async () => { const server = Hapi.server(); const scheme = internals.implementation({ authExpiry: 1100 }); server.auth.scheme('custom', () => scheme); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { minAuthVerifyInterval: 300 }, heartbeat: { interval: 200, timeout: 120 } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ reconnect: false, auth: { headers: { authorization: 'Custom john' } } }); const team = new Teamwork.Team(); client.onDisconnect = () => { team.attend(); }; await Hoek.wait(400); await client.reauthenticate({ headers: { authorization: 'Custom ed' } }); await team.work; expect(scheme.verified).to.equal(['abc', 'xyz', 'xyz', 'xyz']); await server.stop(); }); it('handles auth schemes without a verification method', async () => { const server = Hapi.server(); const scheme = internals.implementation(); delete scheme.verify; server.auth.scheme('custom', () => scheme); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { minAuthVerifyInterval: 100 }, heartbeat: { interval: 50, timeout: 30 } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ reconnect: false, auth: { headers: { authorization: 'Custom john' } } }); await Hoek.wait(200); expect(scheme.verified).to.equal([]); await server.stop(); }); }); }); internals.implementation = function (options = { authExpiry: 300 }) { const users = { john: { user: 'john', scope: 'a' }, ed: { user: 'ed', scope: ['a', 'b', '5'] }, app: { app: 'app', remoteAddress: '192.168.0.1' } }; const artifactsByUser = { john: 'abc', ed: 'xyz' }; const scheme = { authenticate: (request, h) => { const authorization = request.headers.authorization; if (!authorization) { throw Boom.unauthorized(null, 'Custom'); } const parts = authorization.split(/\s+/); const username = parts[1]; const user = users[username]; if (!user) { throw Boom.unauthorized('Unknown user', 'Custom'); } if (user.app && parts[2] === 'remoteAddress' && user.remoteAddress !== request.info.remoteAddress) { throw Boom.unauthorized('remoteAddress is not in whitelist'); } return h.authenticated({ credentials: user, artifacts: { userArtifact: artifactsByUser[username], expires: Date.now() + options.authExpiry } }); }, verified: [], verify: (auth) => { scheme.verified.push(auth.artifacts.userArtifact); if (Date.now() >= auth.artifacts.expires) { throw Boom.unauthorized('Expired'); } } }; return scheme; }; ================================================ FILE: test/client.js ================================================ 'use strict'; const Url = require('url'); const Somever = require('@hapi/somever'); const Boom = require('@hapi/boom'); const Code = require('@hapi/code'); const Hapi = require('@hapi/hapi'); const Hoek = require('@hapi/hoek'); const Lab = require('@hapi/lab'); const Nes = require('../'); const Teamwork = require('@hapi/teamwork'); const internals = {}; const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; describe('Client', () => { const getUri = ({ protocol, address, port }) => Url.format({ protocol, hostname: address, port }); it('defaults options.ws.maxPayload to zero (node)', () => { const client = new Nes.Client('http://localhost'); expect(client._settings.ws).to.equal({ maxPayload: 0 }); }); it('allows setting options.ws.maxPayload (node)', () => { const client = new Nes.Client('http://localhost', { ws: { maxPayload: 100 } }); expect(client._settings.ws).to.equal({ maxPayload: 100 }); }); it('ignores options.ws in browser', async (flags) => { const origWebSocket = global.WebSocket; global.WebSocket = Hoek.ignore; const origIsBrowser = Nes.Client.isBrowser; const Ws = Nes.Client.WebSocket; let length; Nes.Client.WebSocket = function (...args) { length = args.length; if (origWebSocket) { global.WebSocket = origWebSocket; } else { delete global.WebSocket; } Nes.Client.WebSocket = Ws; Nes.Client.isBrowser = origIsBrowser; return new Ws(...args); }; Nes.Client.isBrowser = () => true; const client = new Nes.Client('http://localhost', { ws: { maxPayload: 1000 } }); client.onError = Hoek.ignore; await expect(client.connect()).to.reject(); expect(length).to.equal(1); }); describe('onError', () => { it('logs error to console by default', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const team = new Teamwork.Team(); const orig = console.error; console.error = (err) => { expect(err).to.exist(); console.error = orig; client.disconnect(); team.attend(); }; await client.connect({ reconnect: false }); client._ws.emit('error', new Error('test')); await team.work; }); }); describe('connect()', () => { it('reconnects when server initially down', async () => { const server1 = Hapi.server(); await server1.register({ plugin: Nes, options: { auth: false } }); await server1.start(); const port = server1.info.port; const uri = getUri(server1.info); await server1.stop(); const client = new Nes.Client(uri); client.onError = Hoek.ignore; const team = new Teamwork.Team({ meetings: 2 }); client.onConnect = () => { team.attend(); }; let reconnecting = false; client.onDisconnect = (willReconnect, log) => { reconnecting = willReconnect; team.attend(); }; await expect(client.connect({ delay: 10 })).to.reject('Connection terminated while waiting to connect'); const server2 = Hapi.server({ port }); server2.route({ path: '/', method: 'GET', handler: () => 'ok' }); await server2.register({ plugin: Nes, options: { auth: false } }); await server2.start(); await team.work; expect(reconnecting).to.be.true(); const res = await client.request('/'); expect(res.payload).to.equal('ok'); client.disconnect(); await server2.stop(); }); it('fails to connect', async () => { const client = new Nes.Client('http://0'); const err = await expect(client.connect()).to.reject('Connection terminated while waiting to connect'); expect(err.type).to.equal('ws'); expect(err.isNes).to.equal(true); client.disconnect(); }); it('errors if already connected', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect({ reconnect: false }); await expect(client.connect()).to.reject('Already connected'); client.disconnect(); await server.stop(); }); it('errors if set to reconnect', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await expect(client.connect()).to.reject('Cannot connect while client attempts to reconnect'); client.disconnect(); await server.stop(); }); }); describe('_connect()', () => { it('handles unknown error code', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); client.onError = Hoek.ignore; client.onDisconnect = (willReconnect, log) => { expect(log.explanation).to.equal('Unknown'); client.disconnect(); team.attend(); }; client._ws.onclose({ code: 9999, reason: 'bug', wasClean: false }); await team.work; await server.stop(); }); }); describe('overrideReconnectionAuth()', () => { it('reconnects automatically', async () => { const server = Hapi.server(); server.auth.scheme('custom', (srv, options) => { return { authenticate: (request, h) => { const authorization = request.headers.authorization; if (!authorization) { throw Boom.unauthorized(null, 'Custom'); } const parts = authorization.split(/\s+/); return h.authenticated({ credentials: { user: parts[1] } }); } }; }); server.auth.strategy('default', 'custom'); server.auth.default({ strategy: 'default', mode: 'optional' }); server.route({ method: 'GET', path: '/', config: { auth: { mode: 'optional' }, handler: (request) => { if (request.auth.isAuthenticated) { return request.auth.credentials.user; } return 'nope'; } } }); await server.register(Nes); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; const team = new Teamwork.Team(); let c = 0; client.onConnect = async () => { ++c; if (c === 2) { const { payload } = await client.request('/'); expect(payload).to.equal('john'); client.disconnect(); expect(client.overrideReconnectionAuth({ headers: { authorization: 'Custom steve' } })).to.be.false(); team.attend(); } }; client.onDisconnect = (willReconnect, log) => { if (c === 1) { expect(client.overrideReconnectionAuth({ headers: { authorization: 'Custom john' } })).to.be.true(); } }; await client.connect({ delay: 10 }); const { payload } = await client.request('/'); expect(payload).to.equal('nope'); client._ws.close(); await team.work; await server.stop(); }); }); describe('reauthenticate()', () => { it('uses updated auth information for reconnection', async () => { const server = Hapi.server(); server.auth.scheme('custom', (srv, options) => { return { authenticate: (request, h) => { const authorization = request.headers.authorization; if (!authorization) { throw Boom.unauthorized(null, 'Custom'); } const parts = authorization.split(/\s+/); return h.authenticated({ credentials: { user: parts[1] } }); } }; }); server.auth.strategy('default', 'custom'); server.auth.default({ strategy: 'default', mode: 'optional' }); server.route({ method: 'GET', path: '/', config: { auth: { mode: 'optional' }, handler: (request) => { if (request.auth.isAuthenticated) { return request.auth.credentials.user; } return 'nope'; } } }); await server.register(Nes); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; const team = new Teamwork.Team(); let c = 0; client.onConnect = async () => { ++c; if (c === 2) { const { payload } = await client.request('/'); expect(payload).to.equal('john'); team.attend(); } }; await client.connect({ delay: 10 }); const { payload } = await client.request('/'); expect(payload).to.equal('nope'); const reauthResult = await client.reauthenticate({ headers: { authorization: 'Custom john' } }); expect(reauthResult).to.equal(true); client._ws.close(); await team.work; await server.stop(); }); it('rejects when authentication fails', async () => { const server = Hapi.server(); server.auth.scheme('custom', (srv, options) => { return { authenticate: (request, h) => { const authorization = request.headers.authorization; if (!authorization) { throw Boom.unauthorized(null, 'Custom'); } const parts = authorization.split(/\s+/); const user = parts[1]; if (user !== 'john') { throw Boom.unauthorized('No such user'); } return h.authenticated({ credentials: { user } }); } }; }); server.auth.strategy('default', 'custom'); server.auth.default({ strategy: 'default', mode: 'optional' }); server.route({ method: 'GET', path: '/', config: { auth: { mode: 'optional' }, handler: (request) => { if (request.auth.isAuthenticated) { return request.auth.credentials.user; } return 'nope'; } } }); await server.register(Nes); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; await client.connect({ delay: 10 }); await expect(client.reauthenticate({ headers: { authorization: 'Custom steve' } })).to.reject('No such user'); await server.stop(); }); }); describe('disconnect()', () => { it('ignores when client not connected', () => { const client = new Nes.Client(); client.disconnect(); }); it('ignores when client is disconnecting', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); client.disconnect(); await Hoek.wait(5); client.disconnect(); await server.stop(); }); it('avoids closing a socket in closing state', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); client._ws.close(); await client.disconnect(); await server.stop(); }); it('closes socket while connecting', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const orig = client._connect; client._connect = (...args) => { orig.apply(client, args); client._ws.onerror = client._ws.onclose; }; const reject = expect(client.connect()).to.reject('Connection terminated while waiting to connect'); client.disconnect(); await reject; await server.stop(); }); it('disconnects once', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); let disconnected = 0; client.onDisconnect = (willReconnect, log) => ++disconnected; client.disconnect(); client.disconnect(); await client.disconnect(); await Hoek.wait(50); expect(disconnected).to.equal(1); await server.stop(); }); it('logs manual disconnection request', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); client.onDisconnect = (willReconnect, log) => { expect(log.wasRequested).to.be.true(); team.attend(); }; client.disconnect(); await team.work; await server.stop(); }); it('logs error disconnection request as not requested', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; await client.connect(); const team = new Teamwork.Team(); client.onDisconnect = (willReconnect, log) => { expect(log.wasRequested).to.be.false(); team.attend(); }; client._ws.close(); await team.work; await server.stop(); }); it('logs error disconnection request as not requested after manual disconnect while already disconnected', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; client.disconnect(); await client.connect(); const team = new Teamwork.Team(); client.onDisconnect = (willReconnect, log) => { expect(log.wasRequested).to.be.false(); team.attend(); }; client._ws.close(); await team.work; await server.stop(); }); it('allows closing from inside request callback', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await client.request('/'); client.disconnect(); await Hoek.wait(100); await server.stop(); }); }); describe('_cleanup()', () => { it('ignores when client not connected', () => { const client = new Nes.Client(); client._cleanup(); }); }); describe('_reconnect()', () => { it('reconnects automatically', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); let e = 0; client.onError = (err) => { expect(err).to.exist(); ++e; }; const team = new Teamwork.Team(); let c = 0; client.onConnect = () => { ++c; if (c === 2) { expect(e).to.equal(0); team.attend(); } }; expect(c).to.equal(0); expect(e).to.equal(0); await client.connect({ delay: 10 }); expect(c).to.equal(1); expect(e).to.equal(0); client._ws.close(); await team.work; client.disconnect(); await server.stop(); }); it('aborts reconnecting', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; let c = 0; client.onConnect = () => ++c; await client.connect({ delay: 100 }); client._ws.close(); await Hoek.wait(50); await client.disconnect(); expect(c).to.equal(1); await server.stop(); }); it('does not reconnect automatically', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); let e = 0; client.onError = (err) => { expect(err).to.exist(); ++e; }; let c = 0; client.onConnect = () => ++c; let r = ''; client.onDisconnect = (willReconnect, log) => { r += willReconnect ? 't' : 'f'; }; expect(c).to.equal(0); expect(e).to.equal(0); await client.connect({ reconnect: false, delay: 10 }); expect(c).to.equal(1); expect(e).to.equal(0); client._ws.close(); await Hoek.wait(15); expect(c).to.equal(1); expect(r).to.equal('f'); client.disconnect(); await server.stop(); }); it('overrides max delay', { retry: true }, async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); let c = 0; const now = Date.now(); const team = new Teamwork.Team(); client.onConnect = () => { ++c; // only need to reconnect a few times to confirm override of wait if (c < 3) { client._ws.close(); return; } // The github mac test machine is very slow and requires // this threshold to be increased expect(Date.now() - now).to.be.below(51); team.attend(); }; await client.connect({ delay: 2, maxDelay: 3 }); await team.work; client.disconnect(); await server.stop(); }); it('reconnects automatically (with errors)', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const url = getUri(server.info); const client = new Nes.Client(url); let e = 0; client.onError = (err) => { expect(err).to.exist(); expect(err.message).to.equal('Connection terminated while waiting to connect'); expect(err.type).to.equal('ws'); expect(err.isNes).to.equal(true); ++e; client._url = getUri(server.info); }; let r = ''; client.onDisconnect = (willReconnect, log) => { r += willReconnect ? 't' : 'f'; }; const team = new Teamwork.Team(); let c = 0; client.onConnect = () => { ++c; if (c < 5) { client._ws.close(); if (c === 3) { client._url = 'http://0'; } return; } expect(e).to.equal(1); expect(r).to.equal('ttttt'); team.attend(); }; expect(e).to.equal(0); await client.connect({ delay: 10, maxDelay: 15 }); await team.work; client.disconnect(); await server.stop(); }); it('errors on pending request when closed', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: async () => { await Hoek.wait(10); return 'hello'; } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const request = client.request('/'); client.disconnect(); const err = await expect(request).to.reject('Request failed - server disconnected'); expect(err.type).to.equal('disconnect'); expect(err.isNes).to.equal(true); await server.stop(); }); it('times out', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const orig = client._connect; client._connect = (...args) => { orig.apply(client, args); client._ws.onopen = null; }; let c = 0; client.onConnect = () => ++c; let e = 0; client.onError = async (err) => { ++e; expect(err).to.exist(); expect(err.message).to.equal('Connection timed out'); expect(err.type).to.equal('timeout'); expect(err.isNes).to.equal(true); if (e < 4) { return; } expect(c).to.equal(0); client.disconnect(); await server.stop({ timeout: 1 }); }; await expect(client.connect({ delay: 50, maxDelay: 50, timeout: 50 })).to.reject('Connection timed out'); }); it('limits retries', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); let c = 0; client.onConnect = () => { ++c; client._ws.close(); }; let r = ''; client.onDisconnect = (willReconnect, log) => { r += willReconnect ? 't' : 'f'; }; await client.connect({ delay: 5, maxDelay: 10, retries: 2 }); await Hoek.wait(100); expect(c).to.equal(3); expect(r).to.equal('ttf'); client.disconnect(); await server.stop(); }); it('utilizes every connection retry attempt', async () => { const team = new Teamwork.Team(); const client = new Nes.Client('http://0'); let errorCount = 0; client.onError = (err) => { errorCount++; if (err.message === 'Socket error') { // This is the final error message once retry attempts have run-out team.attend(); } }; try { await client.connect({ delay: 1, retries: 5 }); } catch {} await team.work; // Should see name number of errors as allowed retry attempts expect(errorCount).to.equal(5); client.disconnect(); }); it('aborts reconnect if disconnect is called in between attempts', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const team = new Teamwork.Team(); let c = 0; client.onConnect = async () => { ++c; client._ws.close(); if (c === 1) { setTimeout(() => client.disconnect(), 5); await Hoek.wait(15); expect(c).to.equal(1); team.attend(); } }; await client.connect({ delay: 10 }); await team.work; await server.stop(); }); }); describe('request()', () => { it('defaults to GET', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, headers: '*' } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const { payload, statusCode, headers } = await client.request({ path: '/' }); expect(payload).to.equal('hello'); expect(statusCode).to.equal(200); expect(headers).to.contain({ 'content-type': 'text/html; charset=utf-8' }); client.disconnect(); await server.stop(); }); it('errors when disconnected', async () => { const client = new Nes.Client(); const err = await expect(client.request('/')).to.reject('Failed to send message - server disconnected'); expect(err.type).to.equal('disconnect'); expect(err.isNes).to.equal(true); }); it('errors on invalid payload', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'POST', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const a = { b: 1 }; a.a = a; const err = await expect(client.request({ method: 'POST', path: '/', payload: a })).to.reject(/Converting circular structure to JSON/); expect(err.type).to.equal('user'); expect(err.isNes).to.equal(true); client.disconnect(); await server.stop(); }); it('errors on invalid data', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'POST', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); client._ws.send = () => { throw new Error('boom'); }; const err = await expect(client.request({ method: 'POST', path: '/', payload: 'a' })).to.reject('boom'); expect(err.type).to.equal('ws'); expect(err.isNes).to.equal(true); client.disconnect(); await server.stop(); }); describe('empty response handling', () => { [ { testName: 'handles empty string, no content-type', handler: (request, h) => h.response('').code(200), expectedPayload: '' }, { testName: 'handles null, no content-type', handler: () => null, expectedPayload: null }, { testName: 'handles null, application/json', handler: (request, h) => h.response(null).type('application/json'), expectedPayload: null }, { testName: 'handles empty string, text/plain', handler: (request, h) => h.response('').type('text/plain').code(200), expectedPayload: '' }, { testName: 'handles null, text/plain', handler: (request, h) => h.response(null).type('text/plain'), expectedPayload: null } ].forEach(({ testName, handler, expectedPayload }) => { it(testName, async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, headers: '*' } }); server.route({ method: 'GET', path: '/', handler }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const { payload } = await client.request({ path: '/' }); expect(payload).to.equal(expectedPayload); client.disconnect(); await server.stop(); }); }); }); }); describe('message()', () => { it('errors on timeout', async () => { const onMessage = async (socket, message) => { await Hoek.wait(50); return 'hello'; }; const server = Hapi.server(); await server.register({ plugin: Nes, options: { onMessage } }); await server.start(); const client = new Nes.Client(getUri(server.info), { timeout: 20 }); await client.connect(); const err = await expect(client.message('winning')).to.reject('Request timed out'); expect(err.type).to.equal('timeout'); expect(err.isNes).to.equal(true); await Hoek.wait(50); client.disconnect(); await server.stop(); }); }); describe('_send()', () => { it('catches send error without tracking', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); client._ws.send = () => { throw new Error('failed'); }; const err = await expect(client._send({}, false)).to.reject('failed'); expect(err.type).to.equal('ws'); expect(err.isNes).to.equal(true); client.disconnect(); await server.stop(); }); it('errors on premature send', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const connecting = client.connect(); await expect(client.message('x')).to.reject(); await connecting; client.disconnect(); await server.stop(); }); }); describe('_onMessage', () => { it('ignores invalid incoming message', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: async (request) => { request.server.plugins.nes._listener._sockets._forEach((socket) => { socket._ws.send('{'); }); await Hoek.wait(10); return 'hello'; } }); await server.start(); const client = new Nes.Client(getUri(server.info)); let logged; client.onError = (err) => { logged = err; }; await client.connect(); await client.request('/'); const nodeGte20 = Somever.range().above('19').match(process.versions.node); let expectMsg = /Unexpected end of(?: JSON)? input/; if (nodeGte20) { expectMsg = /Expected property name .+JSON.+/; } expect(logged.message).to.match(expectMsg); expect(logged.type).to.equal('protocol'); expect(logged.isNes).to.equal(true); client.disconnect(); await server.stop(); }); it('reports incomplete message', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: async (request) => { request.server.plugins.nes._listener._sockets._forEach((socket) => { socket._ws.send('+abc'); }); await Hoek.wait(10); return 'hello'; } }); await server.start(); const client = new Nes.Client(getUri(server.info)); let logged; client.onError = (err) => { logged = err; }; await client.connect(); await client.request('/'); expect(logged.message).to.equal('Received an incomplete message'); expect(logged.type).to.equal('protocol'); expect(logged.isNes).to.equal(true); client.disconnect(); await server.stop(); }); it('ignores incoming message with unknown id', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: async (request) => { request.server.plugins.nes._listener._sockets._forEach((socket) => { socket._ws.send('{"id":100,"type":"response","statusCode":200,"payload":"hello","headers":{}}'); }); await Hoek.wait(10); return 'hello'; } }); await server.start(); const client = new Nes.Client(getUri(server.info)); let logged; client.onError = (err) => { logged = err; }; await client.connect(); await client.request('/'); expect(logged.message).to.equal('Received response for unknown request'); expect(logged.type).to.equal('protocol'); expect(logged.isNes).to.equal(true); client.disconnect(); await server.stop(); }); it('ignores incoming message with unknown type', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: async (request) => { request.server.plugins.nes._listener._sockets._forEach((socket) => { socket._ws.send('{"id":2,"type":"unknown","statusCode":200,"payload":"hello","headers":{}}'); }); await Hoek.wait(10); return 'hello'; } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const team = new Teamwork.Team({ meetings: 2 }); const logged = []; client.onError = (err) => { logged.push(err); team.attend(); }; await client.connect(); await expect(client.request('/')).to.reject('Received invalid response'); await team.work; expect(logged[0].message).to.equal('Received unknown response type: unknown'); expect(logged[0].type).to.equal('protocol'); expect(logged[0].isNes).to.equal(true); expect(logged[1].message).to.equal('Received response for unknown request'); expect(logged[1].type).to.equal('protocol'); expect(logged[1].isNes).to.equal(true); client.disconnect(); await server.stop(); }); it('uses error when message is missing', async () => { const server = Hapi.server(); const onSubscribe = (socket, path, params) => { const error = Boom.badRequest(); delete error.output.payload.message; throw error; }; await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/', { onSubscribe }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await expect(client.subscribe('/', Hoek.ignore)).to.reject('Bad Request'); client.disconnect(); await server.stop(); }); }); describe('subscribe()', () => { it('subscribes to a path', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/', {}); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const handler = (update, flags) => { expect(client.subscriptions()).to.equal(['/']); expect(update).to.equal('heya'); team.attend(); }; await client.subscribe('/', handler); server.publish('/', 'heya'); await team.work; client.disconnect(); await server.stop(); }); it('subscribes to a unknown path (pre connect)', async () => { const server = Hapi.server(); const order = []; const onConnection = () => order.push(1); const onDisconnection = () => order.push(2); await server.register({ plugin: Nes, options: { auth: false, onConnection, onDisconnection } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const team = new Teamwork.Team(); client.onDisconnect = async (willReconnect, log) => { expect(log.wasRequested).to.be.false(); await Hoek.wait(50); expect(order).to.equal([1, 2]); team.attend(); }; await client.subscribe('/b', Hoek.ignore); const err = await expect(client.connect()).to.reject('Subscription not found'); expect(err.type).to.equal('server'); expect(err.isNes).to.equal(true); expect(err.statusCode).to.equal(404); expect(client.subscriptions()).to.be.empty(); await team.work; client.disconnect(); await server.stop(); }); it('subscribes to a path (pre connect)', async () => { const server = Hapi.server(); const order = []; const onConnection = () => order.push(1); const onDisconnection = () => order.push(3); await server.register({ plugin: Nes, options: { auth: false, onConnection, onDisconnection } }); const onSubscribe = (socket, path, params) => { order.push(2); }; server.subscription('/', { onSubscribe }); await server.start(); const client = new Nes.Client(getUri(server.info)); const team = new Teamwork.Team(); const handler = async (update, flags) => { expect(update).to.equal('heya'); client.disconnect(); await Hoek.wait(50); expect(order).to.equal([1, 2, 3]); team.attend(); }; await client.subscribe('/', handler); await client.connect(); server.publish('/', 'heya'); await team.work; await server.stop(); }); it('manages multiple subscriptions', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/'); await server.start(); const client1 = new Nes.Client(getUri(server.info)); const client2 = new Nes.Client(getUri(server.info)); await client1.connect(); await client2.connect(); const team = new Teamwork.Team(); const handler = (update, flags) => { expect(update).to.equal('heya'); team.attend(); }; await client1.subscribe('/', handler); await client2.subscribe('/', Hoek.ignore); client2.disconnect(); await Hoek.wait(10); server.publish('/', 'heya'); await team.work; client1.disconnect(); await server.stop(); }); it('ignores publish to a unknown path', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/'); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await client.subscribe('/', Hoek.ignore); delete client._subscriptions['/']; server.publish('/', 'heya'); await Hoek.wait(10); client.disconnect(); await server.stop(); }); it('errors on unknown path', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const err = await expect(client.subscribe('/', Hoek.ignore)).to.reject('Subscription not found'); expect(err.type).to.equal('server'); expect(err.isNes).to.equal(true); client.disconnect(); await server.stop(); }); it('subscribes and immediately unsubscribe to a path (all handlers)', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const handler = (update, flags) => { throw new Error('Must not be called'); }; const err = await expect(client.subscribe('/', handler)).to.reject('Subscription not found'); expect(err.type).to.equal('server'); expect(err.isNes).to.equal(true); await client.unsubscribe('/', null); client.disconnect(); await server.stop(); }); it('subscribes and immediately unsubscribe to a path (single handler)', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const handler = (update, flags) => { throw new Error('Must not be called'); }; const err = await expect(client.subscribe('/', handler)).to.reject('Subscription not found'); expect(err.type).to.equal('server'); expect(err.isNes).to.equal(true); await client.unsubscribe('/', handler); client.disconnect(); await server.stop(); }); it('subscribes and unsubscribes to a path before connecting', () => { const client = new Nes.Client('http://localhost'); const handler1 = (update, flags) => { }; const handler2 = (update, flags) => { }; const handler3 = (update, flags) => { }; const handler4 = (update, flags) => { }; // Initial subscription client.subscribe('/', handler1); client.subscribe('/a', handler2); client.subscribe('/a/b', handler3); client.subscribe('/b/c', handler4); // Ignore duplicates client.subscribe('/', handler1); client.subscribe('/a', handler2); client.subscribe('/a/b', handler3); client.subscribe('/b/c', handler4); // Subscribe to some with additional handlers client.subscribe('/a', handler1); client.subscribe('/b/c', handler2); // Unsubscribe initial set client.unsubscribe('/', handler1); client.unsubscribe('/a', handler2); client.unsubscribe('/a/b', handler3); client.unsubscribe('/b/c', handler4); expect(client.subscriptions()).to.equal(['/a', '/b/c']); }); it('errors on subscribe fail', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/'); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); client._ws.send = () => { throw new Error('failed'); }; const err = await expect(client.subscribe('/', Hoek.ignore)).to.reject('failed'); expect(err.type).to.equal('ws'); expect(err.isNes).to.equal(true); client.disconnect(); await server.stop(); }); it('errors on missing path', async () => { const client = new Nes.Client('http://localhost'); const err = await expect(client.subscribe(null, Hoek.ignore)).to.reject('Invalid path'); expect(err.type).to.equal('user'); expect(err.isNes).to.equal(true); }); it('errors on invalid path', async () => { const client = new Nes.Client('http://localhost'); const err = await expect(client.subscribe('asd', Hoek.ignore)).to.reject('Invalid path'); expect(err.type).to.equal('user'); expect(err.isNes).to.equal(true); }); it('subscribes, unsubscribes, then subscribes again to a path', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/', {}); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const handler1 = async (update1, flags1) => { expect(client.subscriptions()).to.equal(['/']); expect(update1).to.equal('abc'); await client.unsubscribe('/', null); const handler2 = (update2, flags2) => { expect(client.subscriptions()).to.equal(['/']); expect(update2).to.equal('def'); team.attend(); }; await client.subscribe('/', handler2); server.publish('/', 'def'); }; await client.subscribe('/', handler1); server.publish('/', 'abc'); await team.work; client.disconnect(); await server.stop(); }); it('handles revocation', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/', {}); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const handler = (update, flags) => { expect(client.subscriptions()).to.equal([]); expect(update).to.equal('heya'); expect(flags.revoked).to.be.true(); team.attend(); }; await client.subscribe('/', handler); expect(client.subscriptions()).to.equal(['/']); server.eachSocket((socket) => socket.revoke('/', 'heya')); await team.work; client.disconnect(); await server.stop(); }); it('handles revocation (no update)', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/', {}); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); let updated = false; const handler = (update, flags) => { updated = true; }; await client.subscribe('/', handler); expect(client.subscriptions()).to.equal(['/']); const team = new Teamwork.Team(); server.eachSocket(async (socket) => { await socket.revoke('/', null, { ignoreClose: true }); await Hoek.wait(50); expect(client.subscriptions()).to.equal([]); expect(updated).to.be.false(); team.attend(); }); await team.work; client.disconnect(); await server.stop(); }); it('handles revocation on closed websocket when throwIfClosed is false', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); const onUnsubscribe = new Teamwork.Team(); server.subscription('/', { onUnsubscribe: () => onUnsubscribe.work }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); let updated = false; const handler = (update, flags) => { updated = true; }; await client.subscribe('/', handler); expect(client.subscriptions()).to.equal(['/']); const team = new Teamwork.Team(); client.disconnect(); server.eachSocket(async (socket) => { await socket.revoke('/', null, { ignoreClose: true }); await Hoek.wait(50); team.attend(); }); await Hoek.wait(50); onUnsubscribe.attend(); await team.work; expect(updated).to.be.false(); await server.stop(); }); it('throws by default if revoking closed web socket', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); const onUnsubscribe = new Teamwork.Team(); server.subscription('/', { onUnsubscribe: () => onUnsubscribe.work }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); let updated = false; const handler = (update, flags) => { updated = true; }; await client.subscribe('/', handler); expect(client.subscriptions()).to.equal(['/']); const team = new Teamwork.Team(); client.disconnect(); server.eachSocket((socket) => { expect(socket.revoke('/', null)).to.reject('Socket not open'); team.attend(); }); await Hoek.wait(50); onUnsubscribe.attend(); await team.work; expect(updated).to.be.false(); await server.stop(); }); it('queues on premature send', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/', {}); await server.start(); const client = new Nes.Client(getUri(server.info)); const connecting = client.connect(); await expect(client.subscribe('/', Hoek.ignore)).to.not.reject(); await connecting; client.disconnect(); await server.stop(); }); }); describe('unsubscribe()', () => { it('drops all handlers', async () => { const client = new Nes.Client('http://localhost'); client.subscribe('/a/b', Hoek.ignore); client.subscribe('/a/b', Hoek.ignore); await client.unsubscribe('/a/b', null); expect(client.subscriptions()).to.be.empty(); }); it('ignores unknown path', () => { const client = new Nes.Client('http://localhost'); const handler1 = (update, flags) => { }; client.subscribe('/a/b', handler1); client.subscribe('/b/c', Hoek.ignore); client.unsubscribe('/a/b/c', handler1); client.unsubscribe('/b/c', handler1); expect(client.subscriptions()).to.equal(['/a/b', '/b/c']); }); it('errors on missing path', async () => { const client = new Nes.Client('http://localhost'); const err = await expect(client.unsubscribe('', null)).to.reject('Invalid path'); expect(err.type).to.equal('user'); expect(err.isNes).to.equal(true); }); it('errors on invalid path', async () => { const client = new Nes.Client('http://localhost'); const err = await expect(client.unsubscribe('asd', null)).to.reject('Invalid path'); expect(err.type).to.equal('user'); expect(err.isNes).to.equal(true); }); }); describe('_beat()', () => { it('disconnects when server fails to ping', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, heartbeat: { interval: 20, timeout: 10 } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; const team = new Teamwork.Team({ meetings: 2 }); client.onHeartbeatTimeout = (willReconnect) => { expect(willReconnect).to.equal(true); team.attend(); }; client.onDisconnect = (willReconnect, log) => { expect(willReconnect).to.equal(true); team.attend(); }; await client.connect(); clearTimeout(server.plugins.nes._listener._heartbeat); await team.work; client.disconnect(); await server.stop(); }); it('disconnects when server fails to ping (after a few pings)', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, heartbeat: { interval: 20, timeout: 10 } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; const team = new Teamwork.Team(); client.onDisconnect = (willReconnect, log) => { team.attend(); }; await client.connect(); await Hoek.wait(50); clearTimeout(server.plugins.nes._listener._heartbeat); await team.work; client.disconnect(); await server.stop(); }); }); }); ================================================ FILE: test/esm.js ================================================ 'use strict'; const Code = require('@hapi/code'); const Lab = require('@hapi/lab'); const { before, describe, it } = exports.lab = Lab.script(); const expect = Code.expect; describe('import()', () => { let Nes; before(async () => { Nes = await import('../lib/index.js'); }); it('exposes all methods and classes as named imports', () => { expect(Object.keys(Nes)).to.equal([ 'Client', 'default', ...(process.version.match(/^v(\d+)/)[1] >= 23 ? ['module.exports'] : []), 'plugin' ]); }); }); ================================================ FILE: test/index.js ================================================ 'use strict'; const Url = require('url'); const Code = require('@hapi/code'); const Hapi = require('@hapi/hapi'); const Lab = require('@hapi/lab'); const Nes = require('../'); const Teamwork = require('@hapi/teamwork'); const internals = {}; const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; describe('register()', () => { const getUri = ({ protocol, address, port }) => Url.format({ protocol, hostname: address, port }); it('adds websocket support', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, headers: ['Content-Type'] } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const { payload, statusCode, headers } = await client.request('/'); expect(payload).to.equal('hello'); expect(statusCode).to.equal(200); expect(headers).to.equal({ 'content-type': 'text/html; charset=utf-8' }); client.disconnect(); await server.stop(); }); it('calls onConnection callback', async () => { const server = Hapi.server(); const team = new Teamwork.Team(); const onConnection = (ws) => { expect(ws).to.exist(); client.disconnect(); team.attend(); }; await server.register({ plugin: Nes, options: { onConnection, auth: false } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await team.work; await server.stop(); }); it('calls onDisconnection callback', async () => { const server = Hapi.server(); const team = new Teamwork.Team(); const onDisconnection = (ws) => { expect(ws).to.exist(); client.disconnect(); team.attend(); }; await server.register({ plugin: Nes, options: { onDisconnection, auth: false } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); client.disconnect(); await team.work; await server.stop(); }); }); ================================================ FILE: test/listener.js ================================================ 'use strict'; const Url = require('url'); const Boom = require('@hapi/boom'); const Code = require('@hapi/code'); const Hapi = require('@hapi/hapi'); const Hoek = require('@hapi/hoek'); const Lab = require('@hapi/lab'); const Nes = require('../'); const Socket = require('../lib/socket'); const Teamwork = require('@hapi/teamwork'); const internals = {}; const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; describe('Listener', () => { const getUri = ({ protocol, address, port }) => Url.format({ protocol, hostname: address, port }); it('refuses connection while stopping', async () => { const server = Hapi.server(); const onConnection = (socket) => { const orig = socket.disconnect; socket.disconnect = async () => { await Hoek.wait(50); return orig.call(socket); }; }; await server.register({ plugin: Nes, options: { auth: false, onConnection } }); const onUnsubscribe = (socket, path, params) => { server.publish('/', 'ignore'); server.eachSocket(Hoek.ignore); server.broadcast('ignore'); }; server.subscription('/', { onUnsubscribe }); await server.start(); const team = new Teamwork.Team({ meetings: 20 }); const clients = []; for (let i = 0; i < 20; ++i) { const client = new Nes.Client(getUri(server.info)); client.onDisconnect = () => team.attend(); client.onError = Hoek.ignore; await client.connect(); await client.subscribe('/', Hoek.ignore); clients.push(client); } const client2 = new Nes.Client(getUri(server.info)); client2.onError = Hoek.ignore; server.stop(); await expect(client2.connect()).to.reject(); await team.work; }); it('limits number of connections', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, maxConnections: 1 } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const client2 = new Nes.Client(getUri(server.info)); client2.onError = Hoek.ignore; await expect(client2.connect()).to.reject(); client.disconnect(); client2.disconnect(); await server.stop(); }); it('rejects unknown origin', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, origin: ['http://localhost:12345'] } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await expect(client.connect()).to.reject(); client.disconnect(); await server.stop(); }); it('accepts known origin', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, origin: ['http://localhost:12345'] } }); await server.start(); const client = new Nes.Client(getUri(server.info), { ws: { origin: 'http://localhost:12345' } }); await client.connect(); client.disconnect(); await server.stop(); }); it('handles socket errors', async () => { const server = Hapi.server(); const onConnection = (socket) => { socket._ws.emit('error', new Error()); }; await server.register({ plugin: Nes, options: { auth: false, onConnection } }); await server.start(); const client = new Nes.Client(getUri(server.info), { ws: { origin: 'http://localhost:12345' } }); client.onError = Hoek.ignore; await client.connect(); await server.stop(); }); describe('_beat()', () => { it('disconnects client after timeout', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, heartbeat: { interval: 20, timeout: 10 } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; const team = new Teamwork.Team(); client.onDisconnect = () => team.attend(); await client.connect(); expect(client._heartbeatTimeout).to.equal(30); client._onMessage = Hoek.ignore; // Stop processing messages await team.work; client.disconnect(); await server.stop(); }); it('disables heartbeat', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, heartbeat: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); expect(client._heartbeatTimeout).to.be.false(); client.disconnect(); await server.stop(); }); it('pauses heartbeat timeout while replying to client', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, heartbeat: { interval: 200, timeout: 180 } } }); server.route({ method: 'GET', path: '/', handler: async () => { await Hoek.wait(440); return 'hello'; } }); await server.start(); const client = new Nes.Client(getUri(server.info)); let e = 0; client.onError = (err) => { ++e; if (e === 1) { expect(err.message).to.equal('Disconnecting due to heartbeat timeout'); } }; let d = 0; client.onDisconnect = (willReconnect, log) => ++d; await client.connect(); expect(client._heartbeatTimeout).to.equal(380); await client.request('/'); await Hoek.wait(520); expect(d).to.equal(0); client._onMessage = Hoek.ignore; // Stop processing messages await Hoek.wait(480); expect(d).to.equal(1); client.disconnect(); await server.stop(); }); it('does not disconnect newly connecting sockets', async () => { const server = Hapi.server(); let disconnected = 0; const onDisconnection = () => disconnected++; await server.register({ plugin: Nes, options: { onDisconnection, auth: false, heartbeat: { timeout: 14, interval: 25 } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const canary = new Nes.Client(getUri(server.info)); await canary.connect(); const helloTeam = new Teamwork.Team(); const socketOnMessage = Socket.prototype._onMessage; Socket.prototype._onMessage = async function (message) { if (JSON.parse(message).type === 'hello') { await helloTeam.work; } return socketOnMessage.call(this, message); }; const pingTeam = new Teamwork.Team(); const _onMessage = canary._onMessage.bind(canary); canary._onMessage = function (message) { if (message.data === '{"type":"ping"}') { pingTeam.attend(); } return _onMessage(message); }; // wait for the next ping await pingTeam.work; await Hoek.wait(2); const connectPromise = client.connect().catch(Code.fail); // client should not time out for another 2 milliseconds await Hoek.wait(2); // release "hello" message before the timeout hits helloTeam.attend(); await connectPromise; await Hoek.wait(2); // ping should have been answered and connection still active expect(disconnected).to.equal(0); Socket.prototype._onMessage = socketOnMessage; canary.disconnect(); client.disconnect(); await server.stop(); }); it('disconnects sockets that have not fully connected in a long time', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, heartbeat: { interval: 20, timeout: 10 } } }); const socketOnMessage = Socket.prototype._onMessage; Socket.prototype._onMessage = Hoek.ignore; // Do not process messages await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; const team = new Teamwork.Team(); client.onDisconnect = () => team.attend(); client.connect().catch(Hoek.ignore); await team.work; Socket.prototype._onMessage = socketOnMessage; client.disconnect(); await server.stop(); }); }); describe('broadcast()', () => { it('sends message to all clients', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const team = new Teamwork.Team(); client.onUpdate = (message) => { expect(message).to.equal('hello'); team.attend(); }; await client.connect(); server.broadcast('hello'); await team.work; client.disconnect(); await server.stop(); }); it('sends to all user sockets', async () => { const server = Hapi.server(); const implementation = (srv, options) => { return { authenticate: (request, h) => { return h.authenticated({ credentials: { user: request.headers.authorization } }); } }; }; server.auth.scheme('custom', implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); const password = 'some_not_random_password_that_is_also_long_enough'; await server.register({ plugin: Nes, options: { auth: { type: 'direct', password, index: true } } }); await server.start(); const client1 = new Nes.Client(getUri(server.info)); await client1.connect({ auth: { headers: { authorization: 'steve' } } }); const updates = []; const handler = (update) => updates.push(update); client1.onUpdate = handler; const client2 = new Nes.Client(getUri(server.info)); await client2.connect({ auth: { headers: { authorization: 'steve' } } }); client2.onUpdate = handler; server.broadcast('x', { user: 'steve' }); server.broadcast('y', { user: 'john' }); await Hoek.wait(50); expect(updates).to.equal(['x', 'x']); client1.disconnect(); client2.disconnect(); await server.stop(); }); it('errors on missing auth index (disabled)', async () => { const server = Hapi.server(); const implementation = (srv, options) => { return { authenticate: (request, h) => { return h.authenticated({ credentials: { user: request.headers.authorization } }); } }; }; server.auth.scheme('custom', implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); const password = 'some_not_random_password_that_is_also_long_enough'; await server.register({ plugin: Nes, options: { auth: { type: 'direct', password, index: false } } }); await server.start(); expect(() => { server.broadcast('x', { user: 'steve' }); }).to.throw('Socket auth indexing is disabled'); await server.stop(); }); it('errors on missing auth index (no auth)', async () => { const server = Hapi.server(); const implementation = (srv, options) => { return { authenticate: (request, h) => { return h.authenticated({ credentials: { user: request.headers.authorization } }); } }; }; server.auth.scheme('custom', implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); expect(() => { server.broadcast('x', { user: 'steve' }); }).to.throw('Socket auth indexing is disabled'); await server.stop(); }); it('logs invalid message', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); const log = server.events.once('log'); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const a = { b: 1 }; a.c = a; // Circular reference server.broadcast(a); const [event] = await log; expect(event.data).to.equal('update'); client.disconnect(); await server.stop(); }); }); describe('subscription()', () => { it('provides subscription notifications', async () => { const server = Hapi.server(); const onSubscribe = (socket, path, params) => { expect(socket).to.exist(); expect(path).to.equal('/'); client.disconnect(); }; const team = new Teamwork.Team(); const onUnsubscribe = (socket, path, params) => { expect(socket).to.exist(); expect(path).to.equal('/'); expect(params).to.equal({}); team.attend(); }; await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/', { onSubscribe, onUnsubscribe }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await client.subscribe('/', Hoek.ignore); await team.work; client.disconnect(); await server.stop(); }); it('removes subscription notification by path', async () => { const server = Hapi.server(); const onSubscribe = (socket, path, params) => { expect(socket).to.exist(); expect(path).to.equal('/foo'); client.unsubscribe('/foo', null, Hoek.ignore); }; const team = new Teamwork.Team(); const onUnsubscribe = (socket, path, params) => { expect(socket).to.exist(); expect(path).to.equal('/foo'); expect(params).to.equal({ params: 'foo' }); team.attend(); }; await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/{params*}', { onSubscribe, onUnsubscribe }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await client.subscribe('/foo', Hoek.ignore); await team.work; client.disconnect(); await server.stop(); }); it('errors on subscription onSubscribe callback error', async () => { const server = Hapi.server(); const onSubscribe = (socket, path, params) => { throw Boom.badRequest('nah'); }; await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/', { onSubscribe }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await expect(client.subscribe('/', Hoek.ignore)).to.reject('nah'); client.disconnect(); await server.stop(); }); it('errors on subscription onUnsubscribe callback error', async () => { const server = Hapi.server(); const log = server.events.once('log'); const onUnsubscribe = (socket, path, params) => { socket.a.b.c.d(); }; await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/', { onUnsubscribe }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await client.subscribe('/', Hoek.ignore); await client.disconnect(); const [event] = await log; expect(event.tags).to.equal(['nes', 'onUnsubscribe', 'error']); await server.stop(); }); }); describe('publish()', () => { it('publishes to a parameterized path', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/a/{id}'); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal('2'); team.attend(); }; await client.subscribe('/a/b', handler); server.publish('/a/a', '1'); server.publish('/a/b', '2'); await team.work; client.disconnect(); await server.stop(); }); it('publishes with filter', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); const filter = (path, update, options) => { return (update.a === 1); }; server.subscription('/updates', { filter }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal({ a: 1 }); team.attend(); }; await client.subscribe('/updates', handler); server.publish('/updates', { a: 2 }); server.publish('/updates', { a: 1 }); await team.work; client.disconnect(); await server.stop(); }); it('publishes with a filter override', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); const filter = (path, update, options) => { return (update.a === 1 ? { override: { a: 5 } } : false); }; server.subscription('/updates', { filter }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal({ a: 5 }); team.attend(); }; await client.subscribe('/updates', handler); server.publish('/updates', { a: 2 }); server.publish('/updates', { a: 1 }); await team.work; client.disconnect(); await server.stop(); }); it('does not affect other sockets when given a filter override', async () => { const server = Hapi.server(); const implementation = (srv, options) => { return { authenticate: (request, h) => { return h.authenticated({ credentials: { user: request.headers.authorization } }); } }; }; server.auth.scheme('custom', implementation); server.auth.strategy('default', 'custom'); server.auth.default('default'); await server.register({ plugin: Nes, options: { auth: { type: 'direct' } } }); await server.start(); const filter = (path, update, options) => { if (options.credentials.user === 'jane') { return { override: { message: 'hello, jane' } }; } return true; }; server.subscription('/updates', { filter }); const team = new Teamwork.Team({ meetings: 2 }); const client1 = new Nes.Client(getUri(server.info)); await client1.connect({ auth: { headers: { authorization: 'jane' } } }); const handler1 = (update) => { expect(update).to.equal({ message: 'hello, jane' }); team.attend(); }; await client1.subscribe('/updates', handler1); const client2 = new Nes.Client(getUri(server.info)); await client2.connect({ auth: { headers: { authorization: 'john' } } }); const handler2 = (update) => { expect(update).to.equal({ message: 'hello, world' }); // original message team.attend(); }; await client2.subscribe('/updates', handler2); server.publish('/updates', { message: 'hello, world' }); await team.work; client1.disconnect(); client2.disconnect(); await server.stop(); }); it('ignores removed sockets', async () => { const server = Hapi.server(); let filtered = 0; await server.register({ plugin: Nes, options: { auth: false } }); const filter = async (path, update, options) => { await client2.unsubscribe('/updates'); filtered++; }; server.subscription('/updates', { filter }); await server.start(); const client1 = new Nes.Client(getUri(server.info)); client1.onError = Hoek.ignore; await client1.connect(); await client1.subscribe('/updates', Hoek.ignore); const client2 = new Nes.Client(getUri(server.info)); client2.onError = Hoek.ignore; await client2.connect(); await client2.subscribe('/updates', Hoek.ignore); server.publish('/updates', 42); await client1.disconnect(); await server.stop(); expect(filtered).to.equal(1); }); it('publishes with filter (socket)', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); const filter = (path, update, options) => { if (update.a === 1) { options.socket.publish(path, { a: 5 }); } return false; }; server.subscription('/updates', { filter }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal({ a: 5 }); team.attend(); }; await client.subscribe('/updates', handler); server.publish('/updates', { a: 2 }); server.publish('/updates', { a: 1 }); await team.work; client.disconnect(); await server.stop(); }); it('throws on filter system errors', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); const filter = (path, update, options) => { return (update.a.x.y === 1); }; server.subscription('/updates', { filter }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await client.subscribe('/updates', Hoek.ignore); await expect(server.publish('/updates', { a: 2 })).to.reject(); client.disconnect(); await server.stop(); }); it('passes internal options to filter', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); const filter = (path, update, options) => { return (options.internal.b === 1); }; server.subscription('/updates', { filter }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const handler = (update) => { expect(update).to.equal({ a: 1 }); team.attend(); }; await client.subscribe('/updates', handler); server.publish('/updates', { a: 2 }, { internal: { b: 2 } }); server.publish('/updates', { a: 1 }, { internal: { b: 1 } }); await team.work; client.disconnect(); await server.stop(); }); it('publishes to selected user', async () => { const server = Hapi.server(); const implementation = (srv, options) => { return { authenticate: (request, h) => { return h.authenticated({ credentials: { user: 'steve' } }); } }; }; server.auth.scheme('custom', implementation); server.auth.strategy('default', 'custom'); server.auth.default({ strategy: 'default', mode: 'optional' }); const password = 'some_not_random_password_that_is_also_long_enough'; await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); const onUnsubscribe = Hoek.ignore; server.subscription('/', { onUnsubscribe, auth: { mode: 'optional', entity: 'user', index: true } }); await server.start(); const client1 = new Nes.Client(getUri(server.info)); await client1.connect({ auth: { headers: { authorization: 'Custom steve' } } }); const updates = []; const handler = (update) => updates.push(update); await client1.subscribe('/', handler); const client2 = new Nes.Client(getUri(server.info)); await client2.connect({ auth: { headers: { authorization: 'Custom steve' } } }); await client2.subscribe('/', handler); const client3 = new Nes.Client(getUri(server.info)); await client3.connect({ auth: false }); await client3.subscribe('/', handler); server.publish('/', 'heya', { user: 'steve' }); server.publish('/', 'wowa', { user: 'john' }); await Hoek.wait(50); await client1.unsubscribe('/', null); await client2.unsubscribe('/', null); await client3.unsubscribe('/', null); client1.disconnect(); client2.disconnect(); client3.disconnect(); expect(updates).to.equal(['heya', 'heya']); await server.stop(); }); it('publishes to selected user (ignores non-user credentials)', async () => { const server = Hapi.server(); const implementation = (srv, options) => { let count = 0; return { authenticate: (request, h) => { return h.authenticated({ credentials: { user: count++ ? 'steve' : null } }); } }; }; server.auth.scheme('custom', implementation); server.auth.strategy('default', 'custom'); server.auth.default({ strategy: 'default', mode: 'optional' }); const password = 'some_not_random_password_that_is_also_long_enough'; await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); const onUnsubscribe = Hoek.ignore; server.subscription('/', { onUnsubscribe, auth: { mode: 'optional', index: true } }); await server.start(); const client1 = new Nes.Client(getUri(server.info)); await client1.connect({ auth: { headers: { authorization: 'Custom steve' } } }); const updates = []; const handler = (update) => updates.push(update); await client1.subscribe('/', handler); const client2 = new Nes.Client(getUri(server.info)); await client2.connect({ auth: { headers: { authorization: 'Custom steve' } } }); await client2.subscribe('/', handler); server.publish('/', 'heya', { user: 'steve' }); await Hoek.wait(50); await client1.unsubscribe('/', null); await client2.unsubscribe('/', null); client1.disconnect(); client2.disconnect(); expect(updates).to.equal(['heya']); await server.stop(); }); it('ignores unknown path', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.publish('/', 'ignored'); }); it('throws on missing path', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); expect(() => { server.publish('', 'ignored'); }).to.throw('Missing or invalid subscription path: empty'); }); it('throws on invalid path', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); expect(() => { server.publish('a', 'ignored'); }).to.throw('Missing or invalid subscription path: a'); }); it('throws on disabled user mapping', async () => { const server = Hapi.server(); const implementation = (srv, options) => { return { authenticate: (request, h) => { return h.authenticated({ credentials: { user: 'steve' } }); } }; }; server.auth.scheme('custom', implementation); server.auth.strategy('default', 'custom'); server.auth.default({ strategy: 'default', mode: 'optional' }); const password = 'some_not_random_password_that_is_also_long_enough'; await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { mode: 'optional', entity: 'user', index: false } }); await server.start(); await expect(server.publish('/', 'heya', { user: 'steve' })).to.reject('Subscription auth indexing is disabled'); await server.stop(); }); it('throws on disabled auth', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/'); await server.start(); await expect(server.publish('/', 'heya', { user: 'steve' })).to.reject('Subscription auth indexing is disabled'); await server.stop(); }); }); describe('eachSocket()', () => { it('publishes to selected user', async () => { const server = Hapi.server(); const implementation = (srv, options) => { return { authenticate: (request, h) => { return h.authenticated({ credentials: { user: 'steve' } }); } }; }; server.auth.scheme('custom', implementation); server.auth.strategy('default', 'custom'); server.auth.default({ strategy: 'default', mode: 'optional' }); const password = 'some_not_random_password_that_is_also_long_enough'; await server.register({ plugin: Nes, options: { auth: { type: 'direct', password } } }); server.subscription('/', { auth: { mode: 'optional', entity: 'user', index: true } }); await server.start(); const client1 = new Nes.Client(getUri(server.info)); await client1.connect({ auth: { headers: { authorization: 'Custom steve' } } }); const updates = []; const handler = (update) => updates.push(update); await client1.subscribe('/', handler); const client2 = new Nes.Client(getUri(server.info)); await client2.connect({ auth: { headers: { authorization: 'Custom steve' } } }); await client2.subscribe('/', handler); const client3 = new Nes.Client(getUri(server.info)); await client3.connect({ auth: false }); await client3.subscribe('/', handler); server.eachSocket((socket) => socket.publish('/', 'heya'), { user: 'steve', subscription: '/' }); server.eachSocket((socket) => socket.publish('/', 'wowa'), { user: 'john', subscription: '/' }); await Hoek.wait(50); await client1.unsubscribe('/', null); await client2.unsubscribe('/', null); await client3.unsubscribe('/', null); client1.disconnect(); client2.disconnect(); client3.disconnect(); expect(updates).to.equal(['heya', 'heya']); await server.stop(); }); it('throws on missing subscription with user option', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); expect(() => { server.eachSocket(Hoek.ignore, { user: 'steve' }); }).to.throw('Cannot specify user filter without a subscription path'); }); }); describe('_subscribe()', () => { it('subscribes to two paths on same subscription', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/{id}', {}); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); let called = false; const handler1 = (update1) => { called = true; }; await client.subscribe('/5', handler1); const team = new Teamwork.Team(); const handler2 = async (update2) => { expect(called).to.be.true(); await client.disconnect(); await Hoek.wait(10); await server.stop(); const listener = server.plugins.nes._listener; expect(listener._sockets._items).to.equal({}); const match = listener._router.route('sub', '/5'); expect(match.route.subscribers._items).to.equal({}); team.attend(); }; await client.subscribe('/6', handler2); server.publish('/5', 'a'); server.publish('/6', 'b'); await team.work; }); it('errors on double subscribe to same paths', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/{id}', {}); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await client.subscribe('/5', Hoek.ignore); const request = { type: 'sub', path: '/5' }; await client._send(request, true); client.disconnect(); await server.stop(); }); it('errors on path with query', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/'); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await expect(client.subscribe('/?5', Hoek.ignore)).to.reject('Subscription path cannot contain query'); client.disconnect(); await server.stop(); }); }); describe('Sockets', () => { describe('eachSocket()', () => { const countSockets = async (server, options) => { let seen = 0; await server.eachSocket((socket) => { expect(socket).to.exist(); seen++; }, options); return seen; }; it('returns connected sockets', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); expect(await countSockets(server)).to.equal(1); client.disconnect(); await server.stop(); }); it('returns sockets on a subscription', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/a/{id}'); server.subscription('/b'); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await client.subscribe('/b', Hoek.ignore); const client2 = new Nes.Client(getUri(server.info)); await client2.connect(); await client2.subscribe('/a/b', Hoek.ignore); expect(await countSockets(server)).to.equal(2); expect(await countSockets(server, { subscription: '/a/a' })).to.equal(0); expect(await countSockets(server, { subscription: '/a/b' })).to.equal(1); expect(await countSockets(server, { subscription: '/b' })).to.equal(1); expect(await countSockets(server, { subscription: '/foo' })).to.equal(0); client.disconnect(); client2.disconnect(); await server.stop(); }); }); }); describe('_generateId()', () => { it('rolls over when reached max sockets per millisecond', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); const listener = server.plugins.nes._listener; listener._socketCounter = 99999; let id = listener._generateId(); expect(id.split(':')[4]).to.equal('99999'); id = listener._generateId(); expect(id.split(':')[4]).to.equal('10000'); }); }); }); ================================================ FILE: test/socket.js ================================================ 'use strict'; const Url = require('url'); const Boom = require('@hapi/boom'); const Code = require('@hapi/code'); const Hapi = require('@hapi/hapi'); const Hoek = require('@hapi/hoek'); const Lab = require('@hapi/lab'); const Nes = require('../'); const Teamwork = require('@hapi/teamwork'); const Ws = require('ws'); const internals = {}; const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; describe('Socket', () => { const getUri = ({ protocol, address, port }) => Url.format({ protocol, hostname: address, port }); it('exposes app namespace', async () => { const server = Hapi.server(); const onConnection = (socket) => { socket.app.x = 'hello'; }; await server.register({ plugin: Nes, options: { onConnection, auth: false } }); server.route({ method: 'GET', path: '/', handler: (request) => { expect(request.socket.server).to.exist(); return request.socket.app.x; } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const { payload, statusCode } = await client.request('/'); expect(payload).to.equal('hello'); expect(statusCode).to.equal(200); client.disconnect(); await server.stop(); }); it('includes socket info', async () => { const team = new Teamwork.Team(); const server = Hapi.server(); const onConnection = (socket) => { // 127.0.0.1 on node v14 and v16, ::1 on node v18 since DNS resolved to IPv6. expect(socket.info.remoteAddress).to.match(/^127\.0\.0\.1|::1$/); expect(socket.info.remotePort).to.be.a.number(); team.attend(); }; await server.register({ plugin: Nes, options: { onConnection, auth: false } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); client.disconnect(); await team.work; await server.stop(); }); describe('disconnect()', () => { it('closes connection', async () => { const server = Hapi.server(); const onMessage = (socket, message) => socket.disconnect(); await server.register({ plugin: Nes, options: { onMessage } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const team = new Teamwork.Team(); client.onDisconnect = () => team.attend(); await client.connect(); await expect(client.message('winning')).to.reject(); await team.work; client.disconnect(); await server.stop(); }); }); describe('send()', () => { it('sends custom message', async () => { const server = Hapi.server(); const onConnection = (socket) => socket.send('goodbye'); await server.register({ plugin: Nes, options: { onConnection } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const team = new Teamwork.Team(); client.onUpdate = (message) => { expect(message).to.equal('goodbye'); team.attend(); }; await client.connect(); await team.work; client.disconnect(); await server.stop(); }); it('sends custom message (callback)', async () => { let sent = false; const onConnection = async (socket) => { await socket.send('goodbye'); sent = true; }; const server = Hapi.server(); await server.register({ plugin: Nes, options: { onConnection } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const team = new Teamwork.Team(); client.onUpdate = (message) => { expect(message).to.equal('goodbye'); expect(sent).to.be.true(); team.attend(); }; await client.connect(); await team.work; client.disconnect(); await server.stop(); }); }); describe('publish()', () => { it('updates a single socket subscription on subscribe', async () => { const server = Hapi.server(); const onSubscribe = (socket, path, params) => { expect(socket).to.exist(); expect(path).to.equal('/1'); expect(params.id).to.equal('1'); socket.publish(path, 'Initial state'); }; await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/{id}', { onSubscribe }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const each = (update) => { expect(update).to.equal('Initial state'); team.attend(); }; client.subscribe('/1', each); await team.work; client.disconnect(); await server.stop(); }); it('passes a callback', async () => { const server = Hapi.server(); const onSubscribe = (socket, path, params) => { expect(socket).to.exist(); expect(path).to.equal('/1'); expect(params.id).to.equal('1'); socket.publish(path, 'Initial state').then(() => socket.publish(path, 'Updated state')); // Does not wait for publish callback }; await server.register({ plugin: Nes, options: { auth: false } }); server.subscription('/{id}', { onSubscribe }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); let count = 0; const each = (update) => { ++count; if (count === 1) { expect(update).to.equal('Initial state'); } else { expect(update).to.equal('Updated state'); team.attend(); } }; client.subscribe('/1', each); await team.work; client.disconnect(); await server.stop(); }); }); describe('_send()', () => { it('errors on invalid message', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); const log = server.events.once('log'); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; await client.connect(); const a = { id: 1, type: 'other' }; a.c = a; // Circular reference server.plugins.nes._listener._sockets._forEach((socket) => { socket._send(a, null, Hoek.ignore); }); const [event] = await log; expect(event.data).to.equal('other'); client.disconnect(); await server.stop(); }); it('reuses previously stringified value', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: (request, h) => { return h.response(JSON.stringify({ a: 1, b: 2 })).type('application/json'); } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const { payload, statusCode } = await client.request('/'); expect(payload).to.equal({ a: 1, b: 2 }); expect(statusCode).to.equal(200); client.disconnect(); await server.stop(); }); it('ignores previously stringified value when no content-type header', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: () => JSON.stringify({ a: 1, b: 2 }) }); server.ext('onPreResponse', (request, h) => { request.response._contentType = null; return h.continue; }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const { payload, statusCode } = await client.request('/'); expect(payload).to.equal('{"a":1,"b":2}'); expect(statusCode).to.equal(200); client.disconnect(); await server.stop(); }); }); describe('_flush()', () => { it('breaks large message into smaller packets', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, payload: { maxChunkChars: 5 } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const text = 'this is a message longer than 5 bytes'; const team = new Teamwork.Team(); client.onUpdate = (message) => { expect(message).to.equal(text); team.attend(); }; await client.connect(); server.broadcast(text); await team.work; client.disconnect(); await server.stop(); }); it('leaves message small enough to fit into single packets', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, payload: { maxChunkChars: 100 } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); const text = 'this is a message shorter than 100 bytes'; const team = new Teamwork.Team(); client.onUpdate = (message) => { expect(message).to.equal(text); team.attend(); }; await client.connect(); server.broadcast(text); await team.work; client.disconnect(); await server.stop(); }); it('errors on socket send error', async () => { const server = Hapi.server(); const onConnection = (socket) => { socket._ws.send = (message, next) => next(new Error()); }; await server.register({ plugin: Nes, options: { auth: false, payload: { maxChunkChars: 5 }, onConnection } }); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; await expect(client.connect({ timeout: 100 })).to.reject('Request failed - server disconnected'); client.disconnect(); await server.stop(); }); }); describe('_active()', () => { it('shows active mode while publishing', async () => { const server = Hapi.server(); let connection; const onConnection = (socket) => { connection = socket; }; await server.register({ plugin: Nes, options: { onConnection, auth: false, payload: { maxChunkChars: 5 } } }); server.subscription('/{id}', {}); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const team = new Teamwork.Team(); const handler = (update) => { team.attend(); }; await client.subscribe('/5', handler); server.publish('/5', '1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890'); connection._pinged = false; expect(connection._active()).to.be.true(); await team.work; client.disconnect(); await server.stop(); }); }); describe('_onMessage()', () => { it('supports route id', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', config: { id: 'resource', handler: () => 'hello' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const { payload, statusCode } = await client.request('resource'); expect(payload).to.equal('hello'); expect(statusCode).to.equal(200); client.disconnect(); await server.stop(); }); it('errors on unknown route id', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', config: { id: 'resource', handler: () => 'hello' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const err = await expect(client.request('something')).to.reject(); expect(err.statusCode).to.equal(404); client.disconnect(); await server.stop(); }); it('errors on wildcard method route id', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: '*', path: '/', config: { id: 'resource', handler: () => 'hello' } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const err = await expect(client.request('resource')).to.reject(); expect(err.statusCode).to.equal(400); client.disconnect(); await server.stop(); }); it('errors on invalid request message', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Ws(getUri(server.info)); client.onerror = Hoek.ignore; const team = new Teamwork.Team(); client.on('message', (data) => { const message = JSON.parse(data); expect(message.payload).to.equal({ error: 'Bad Request', message: 'Cannot parse message' }); expect(message.statusCode).to.equal(400); team.attend(); }); client.on('open', () => { client.send('{', (err) => { expect(err).to.not.exist(); }); }); await team.work; client.close(); await server.stop(); }); it('errors on auth endpoint request', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: { password: 'password' } } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const err = await expect(client.request('/nes/auth')).to.reject(); expect(err.statusCode).to.equal(404); client.disconnect(); await server.stop(); }); it('errors on missing id', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Ws(getUri(server.info)); client.onerror = Hoek.ignore; const team = new Teamwork.Team(); client.on('message', (data) => { const message = JSON.parse(data); expect(message.payload).to.equal({ error: 'Bad Request', message: 'Message missing id' }); expect(message.statusCode).to.equal(400); expect(message.type).to.equal('request'); team.attend(); }); client.on('open', () => client.send(JSON.stringify({ type: 'request', method: 'GET', path: '/' }), Hoek.ignore)); await team.work; client.close(); await server.stop(); }); it('errors on uninitialized connection', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Ws(getUri(server.info)); client.onerror = Hoek.ignore; const team = new Teamwork.Team(); client.on('message', (data) => { const message = JSON.parse(data); expect(message.payload.message).to.equal('Connection is not initialized'); team.attend(); }); client.on('open', () => client.send(JSON.stringify({ id: 1, type: 'request', path: '/' }), Hoek.ignore)); await team.work; client.close(); await server.stop(); }); it('errors on missing method', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Ws(getUri(server.info)); client.onerror = Hoek.ignore; const team = new Teamwork.Team(); client.on('message', (data) => { const message = JSON.parse(data); if (message.id !== 2) { client.send(JSON.stringify({ id: 2, type: 'request', path: '/' }), Hoek.ignore); return; } expect(message.payload).to.equal({ error: 'Bad Request', message: 'Message missing method' }); expect(message.statusCode).to.equal(400); expect(message.type).to.equal('request'); team.attend(); }); client.on('open', () => client.send(JSON.stringify({ id: 1, type: 'hello', version: '2' }), Hoek.ignore)); await team.work; client.close(); await server.stop(); }); it('errors on missing path', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Ws(getUri(server.info)); client.onerror = Hoek.ignore; const team = new Teamwork.Team(); client.on('message', (data) => { const message = JSON.parse(data); if (message.id !== 2) { client.send(JSON.stringify({ id: 2, type: 'request', method: 'GET' }), Hoek.ignore); return; } expect(message.payload).to.equal({ error: 'Bad Request', message: 'Message missing path' }); expect(message.statusCode).to.equal(400); expect(message.type).to.equal('request'); team.attend(); }); client.on('open', () => client.send(JSON.stringify({ id: 1, type: 'hello', version: '2' }), Hoek.ignore)); await team.work; client.close(); await server.stop(); }); it('errors on unknown type', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Ws(getUri(server.info)); client.onerror = Hoek.ignore; const team = new Teamwork.Team(); client.on('message', (data) => { const message = JSON.parse(data); if (message.id !== 2) { client.send(JSON.stringify({ id: 2, type: 'unknown' }), Hoek.ignore); return; } expect(message.payload).to.equal({ error: 'Bad Request', message: 'Unknown message type' }); expect(message.statusCode).to.equal(400); expect(message.type).to.equal('unknown'); team.attend(); }); client.on('open', () => client.send(JSON.stringify({ id: 1, type: 'hello', version: '2' }), Hoek.ignore)); await team.work; client.close(); await server.stop(); }); it('errors on incorrect version', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Ws(getUri(server.info)); client.onerror = Hoek.ignore; const team = new Teamwork.Team(); client.on('message', (data) => { const message = JSON.parse(data); expect(message.payload).to.equal({ error: 'Bad Request', message: 'Incorrect protocol version (expected 2 but received 1)' }); expect(message.statusCode).to.equal(400); team.attend(); }); client.on('open', () => client.send(JSON.stringify({ id: 1, type: 'hello', version: '1' }), Hoek.ignore)); await team.work; client.close(); await server.stop(); }); it('errors on missing version', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Ws(getUri(server.info)); client.onerror = Hoek.ignore; const team = new Teamwork.Team(); client.on('message', (data) => { const message = JSON.parse(data); expect(message.payload).to.equal({ error: 'Bad Request', message: 'Incorrect protocol version (expected 2 but received none)' }); expect(message.statusCode).to.equal(400); team.attend(); }); client.on('open', () => client.send(JSON.stringify({ id: 1, type: 'hello' }), Hoek.ignore)); await team.work; client.close(); await server.stop(); }); it('errors on missing type', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); await server.start(); const client = new Ws(getUri(server.info)); client.onerror = Hoek.ignore; const team = new Teamwork.Team(); client.on('message', (data) => { const message = JSON.parse(data); expect(message.payload).to.equal({ error: 'Bad Request', message: 'Cannot parse message' }); expect(message.statusCode).to.equal(400); team.attend(); }); client.on('open', () => client.send(JSON.stringify({ id: 1 }), Hoek.ignore)); await team.work; client.close(); await server.stop(); }); it('unsubscribes to two paths on same subscription', async () => { const server = Hapi.server(); const onMessage = (socket, message) => 'b'; await server.register({ plugin: Nes, options: { auth: false, onMessage } }); server.subscription('/{id}', {}); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await client.subscribe('/5', Hoek.ignore); const team = new Teamwork.Team(); const handler = async (update) => { client.unsubscribe('/5', null, Hoek.ignore); client.unsubscribe('/6', null, Hoek.ignore); await client.message('a'); const listener = server.plugins.nes._listener; const match = listener._router.route('sub', '/5'); expect(match.route.subscribers._items).to.equal({}); team.attend(); }; await client.subscribe('/6', handler); server.publish('/6', 'b'); await team.work; client.disconnect(); await server.stop(); }); it('ignores double unsubscribe to same subscription', async () => { const server = Hapi.server(); const onMessage = (socket, message) => 'b'; await server.register({ plugin: Nes, options: { auth: false, onMessage } }); server.subscription('/{id}', {}); await server.start(); const client = new Nes.Client(getUri(server.info)); client.onError = Hoek.ignore; await client.connect(); const team = new Teamwork.Team(); const handler = async (update) => { await client.unsubscribe('/6', null); client._send({ type: 'unsub', path: '/6' }); await client.message('a'); const listener = server.plugins.nes._listener; const match = listener._router.route('sub', '/6'); expect(match.route.subscribers._items).to.equal({}); team.attend(); }; await client.subscribe('/6', handler); server.publish('/6', 'b'); await team.work; client.disconnect(); await server.stop(); }); }); describe('_processRequest()', () => { it('exposes socket to request', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: (request) => request.socket.id }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const { payload } = await client.request('/'); expect(payload).to.equal(client.id); client.disconnect(); await server.stop(); }); it('passed headers', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false, headers: '*' } }); server.route({ method: 'GET', path: '/', handler: (request) => ('hello ' + request.headers.a) }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const { payload, statusCode, headers } = await client.request({ path: '/', headers: { a: 'b' } }); expect(payload).to.equal('hello b'); expect(statusCode).to.equal(200); expect(headers).to.contain({ 'content-type': 'text/html; charset=utf-8' }); client.disconnect(); await server.stop(); }); it('errors on authorization header', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); server.route({ method: 'GET', path: '/', handler: () => 'hello' }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); await expect(client.request({ path: '/', headers: { Authorization: 'something' } })).to.reject('Cannot include an Authorization header'); client.disconnect(); await server.stop(); }); }); describe('_processMessage()', () => { it('calls onMessage callback', async () => { const server = Hapi.server(); const onMessage = (socket, message) => { expect(message).to.equal('winning'); return 'hello'; }; await server.register({ plugin: Nes, options: { onMessage } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const { payload } = await client.message('winning'); expect(payload).to.equal('hello'); client.disconnect(); await server.stop(); }); it('sends errors from callback (raw)', async () => { const onMessage = (socket, message) => { expect(message).to.equal('winning'); throw new Error('failed'); }; const server = Hapi.server(); await server.register({ plugin: Nes, options: { onMessage } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const err = await expect(client.message('winning')).to.reject('An internal server error occurred'); expect(err.statusCode).to.equal(500); client.disconnect(); await server.stop(); }); it('sends errors from callback (boom)', async () => { const onMessage = (socket, message) => { expect(message).to.equal('winning'); throw Boom.badRequest('failed'); }; const server = Hapi.server(); await server.register({ plugin: Nes, options: { onMessage } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const err = await expect(client.message('winning')).to.reject('failed'); expect(err.statusCode).to.equal(400); client.disconnect(); await server.stop(); }); it('sends errors from callback (code)', async () => { const onMessage = (socket, message) => { expect(message).to.equal('winning'); const error = Boom.badRequest(); error.output.payload = {}; throw error; }; const server = Hapi.server(); await server.register({ plugin: Nes, options: { onMessage } }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const err = await expect(client.message('winning')).to.reject('Error'); expect(err.statusCode).to.equal(400); client.disconnect(); await server.stop(); }); it('errors if missing onMessage callback', async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: {} }); await server.start(); const client = new Nes.Client(getUri(server.info)); await client.connect(); const err = await expect(client.message('winning')).to.reject('Not Implemented'); expect(err.statusCode).to.equal(501); client.disconnect(); await server.stop(); }); }); }); ================================================ FILE: test/types/client.ts ================================================ import { types as lab } from '@hapi/lab'; import { expect } from '@hapi/code'; const { expect: check } = lab; import { Client } from '../../lib/client'; const init = () => { const client = new Client('ws://localhost', { ws: { // optional origin: 'http://localhost:12345', maxPayload: 1000, headers: { cookie: 'xnes=123' } } }); client.connect() client.connect({ auth: { headers: { authorization: 'Basic am9objpzZWNyZXQ=' } } }); client.request('hello'); client.reauthenticate({ headers: { authorization: 'Bearer am9objpzZWNyZXQ=' } }); client.onConnect = () => console.log('connected'); client.onDisconnect = (willReconnect) => console.log('disconnected', willReconnect); client.onError = (err) => console.error(err); client.onUpdate = (update) => console.log(update); client.connect(); client.subscribe('/item/5', (update) => console.log(update)); client.unsubscribe('/item/5'); client.disconnect(); } ================================================ FILE: test/types/server.ts ================================================ import { types as lab } from '@hapi/lab'; const { expect: check } = lab; import * as Hapi from '@hapi/hapi'; import { Plugin, ServerRegisterPluginObjectDirect } from '@hapi/hapi'; import * as NesPlugin from '../../lib'; import { Nes, Client, plugin } from '../../lib'; const init = async () => { const server = Hapi.server(); await server.register(NesPlugin); const nesPlugin: ServerRegisterPluginObjectDirect = { plugin, options: { auth: { cookie: 'wee', endpoint: '/hello', id: 'hello', route: 'woo', type: 'cookie', domain: '', index: true, iron: { encryption: { algorithm: 'aes-128-ctr', iterations: 4, minPasswordlength: 8, saltBits: 16 }, integrity: { algorithm: 'aes-128-ctr', iterations: 4, minPasswordlength: 8, saltBits: 16 }, localtimeOffsetMsec: 10 * 1000, timestampSkewSec: 10 * 1000, ttl: 10 * 1000 } }, async onMessage(socket, _message) { const message = _message as { test: true }; if (message.test === true) { await socket.send({ hey: 'man' }) } }, } } await server.register(nesPlugin); check.type>(NesPlugin.plugin); server.subscription('/item/{id}'); server.broadcast('welcome'); server.route({ method: 'GET', path: '/test', handler: (request) => { check.type(request.socket); return { test: 'passes ' + request.socket.id }; } }); server.publish('/item/5', { id: 5, status: 'complete' }); server.publish('/item/6', { id: 6, status: 'initial' }); const socket: Nes.Socket = {} as any; socket.send('message'); socket.publish('path', 'message'); socket.revoke('path', 'message'); socket.disconnect(); check.type< (p: string, m: unknown, o: Nes.PublishOptions) => void >(server.publish); const client = new Client('ws://localhost'); client.connect(); };