Showing preview only (330K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
<a href="https://hapi.dev"><img src="https://raw.githubusercontent.com/hapijs/assets/master/images/family.png" width="180px" align="right" /></a>
# @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<string, string>;
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<string, string>;
payload?: Record<string, string>;
};
/**
* 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<R> = {
payload: R;
statusCode: number;
headers: Record<string, string>;
}
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<HTTP_METHODS | Lowercase<HTTP_METHODS>, '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<string, string>;
/**
* 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<void>;
/**
* Disconnects the client from the server and
* stops future reconnects.
*
* https://github.com/hapijs/nes/blob/master/API.md#await-clientdisconnect
*/
disconnect(): Promise<void>;
/**
* 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 <R = any>(path: string): Promise<NesReqRes<R>>;
/**
* 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 <R = any>(options: ClientRequestOptions): Promise<NesReqRes<R>>;
/**
* 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 <R = any>(message: unknown): Promise<R>;
/**
* 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<void>;
/**
* 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<void>;
/**
* 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<unknown>;
}
================================================
FILE: lib/client.js
================================================
'use strict';
/*
(hapi)nes WebSocket Client (https://github.com/hapijs/nes)
Copyright (c) 2015-2016, Eran Hammer <eran@hammer.io> 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<U, A> | null;
artifacts: Hapi.AuthArtifacts | null;
}
export interface Socket<
App extends object = {},
Auth extends SocketAuthObject<any, any> = SocketAuthObject<any, any>
> {
id: string,
app: App,
auth: Auth,
info: {
remoteAddress: string,
remotePort: number,
'x-forwarded-for'?: string,
}
server: Hapi.Server,
disconnect(): Promise<void>,
send(message: unknown): Promise<void>,
publish(path: string, message: unknown): Promise<void>,
revoke(
path: string,
message?: unknown | null,
options?: {
ignoreClose?: boolean,
}
): Promise<void>,
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<S extends Socket = Socket<any, any>> {
/**
* 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<FilterReturn>),
/**
* 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<any, any> = SocketAuthObject<any, any>
> {
/**
* A function invoked for each incoming connection
* @param socket The `Socket` object of incoming
* connection
*/
onConnection?: (socket: Socket<App, Auth>) => void
/**
* A function invoked for each disconnection
* @param socket The `Socket` object of incoming
* connection
*/
onDisconnection?: (socket: Socket<App, Auth>) => void
/**
* A function used to receive custom client messages
* @param message The message sent by the client
* @returns
*/
onMessage?: (
socket: Socket<App, Auth>,
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<any>['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<Nes.PluginOptions>;
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);
clien
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
SYMBOL INDEX (22 symbols across 3 files)
FILE: lib/client.d.ts
type HTTP_METHODS (line 5) | type HTTP_METHODS = 'ACL' | 'BIND' | 'CHECKOUT' | 'CONNECT' | 'COPY' | '...
type ErrorType (line 10) | type ErrorType = (
type ErrorCodes (line 19) | type ErrorCodes = {
type NesLog (line 35) | type NesLog = {
type NesError (line 45) | interface NesError extends Error {
type ClientConnectOptions (line 55) | interface ClientConnectOptions {
type NesReqRes (line 114) | type NesReqRes<R> = {
type ClientRequestOptions (line 120) | interface ClientRequestOptions {
type NesSubHandler (line 147) | interface NesSubHandler {
class Client (line 169) | class Client {
FILE: lib/index.d.ts
type SocketAuthObject (line 30) | interface SocketAuthObject<
type Socket (line 39) | interface Socket<
type ClientOpts (line 65) | interface ClientOpts {
type BroadcastOptions (line 79) | interface BroadcastOptions {
type FilterReturn (line 92) | type FilterReturn = (
type SubscriptionOptions (line 104) | interface SubscriptionOptions<S extends Socket = Socket<any, any>> {
type PublishOptions (line 203) | interface PublishOptions {
type EachSocketOptions (line 221) | interface EachSocketOptions {
type PluginOptions (line 243) | interface PluginOptions<
type Server (line 376) | interface Server {
type Request (line 441) | interface Request {
FILE: test/types/server.ts
method onMessage (line 47) | async onMessage(socket, _message) {
Condensed preview — 21 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (338K chars).
[
{
"path": ".github/workflows/ci-plugin.yml",
"chars": 193,
"preview": "name: ci\n\non:\n push:\n branches:\n - master\n - next\n pull_request:\n workflow_dispatch:\n\njobs:\n test:\n "
},
{
"path": ".gitignore",
"chars": 108,
"preview": "**/node_modules\n**/package-lock.json\n\ncoverage.*\n\n**/.DS_Store\n**/._*\n\n**/*.pem\n\n**/.vs\n**/.vscode\n**/.idea\n"
},
{
"path": "API.md",
"chars": 30812,
"preview": "\n## Introduction\n\n**nes** adds native WebSocket support to [**hapi**](https://github.com/hapijs/hapi)-based application\n"
},
{
"path": "LICENSE.md",
"chars": 1487,
"preview": "Copyright (c) 2016-2022, Project contributors\nCopyright (c) 2016-2020, Sideway Inc\nAll rights reserved.\n\nRedistribution "
},
{
"path": "PROTOCOL.md",
"chars": 9971,
"preview": "# nes Protocol v2.4.x\n\n## Message\n\nThe nes protocol consists of JSON messages sent between the client and server.\n\nEach "
},
{
"path": "README.md",
"chars": 1018,
"preview": "<a href=\"https://hapi.dev\"><img src=\"https://raw.githubusercontent.com/hapijs/assets/master/images/family.png\" width=\"18"
},
{
"path": "lib/client.d.ts",
"chars": 10843,
"preview": "import type { ClientRequestArgs } from \"http\";\nimport type { ClientOptions } from \"ws\";\n\n// Same as exported type in @ha"
},
{
"path": "lib/client.js",
"chars": 21091,
"preview": "'use strict';\n\n/*\n (hapi)nes WebSocket Client (https://github.com/hapijs/nes)\n Copyright (c) 2015-2016, Eran Hamme"
},
{
"path": "lib/index.d.ts",
"chars": 13881,
"preview": "import * as Hapi from '@hapi/hapi';\n\nimport * as Iron from '@hapi/iron';\nimport { Client } from './client';\n\nimport {\n "
},
{
"path": "lib/index.js",
"chars": 6927,
"preview": "'use strict';\n\nconst Cryptiles = require('@hapi/cryptiles');\nconst Hoek = require('@hapi/hoek');\nconst Iron = require('@"
},
{
"path": "lib/listener.js",
"chars": 16352,
"preview": "'use strict';\n\nconst Boom = require('@hapi/boom');\nconst Bounce = require('@hapi/bounce');\nconst Call = require('@hapi/c"
},
{
"path": "lib/socket.js",
"chars": 15769,
"preview": "'use strict';\n\nconst Boom = require('@hapi/boom');\nconst Bounce = require('@hapi/bounce');\nconst Cryptiles = require('@h"
},
{
"path": "package.json",
"chars": 1072,
"preview": "{\n \"name\": \"@hapi/nes\",\n \"description\": \"WebSocket adapter plugin for hapi routes\",\n \"version\": \"14.0.1\",\n \"reposito"
},
{
"path": "test/auth.js",
"chars": 54081,
"preview": "'use strict';\n\nconst Url = require('url');\n\nconst Boom = require('@hapi/boom');\nconst Code = require('@hapi/code');\ncons"
},
{
"path": "test/client.js",
"chars": 60377,
"preview": "'use strict';\n\nconst Url = require('url');\n\nconst Somever = require('@hapi/somever');\nconst Boom = require('@hapi/boom')"
},
{
"path": "test/esm.js",
"chars": 592,
"preview": "'use strict';\n\nconst Code = require('@hapi/code');\nconst Lab = require('@hapi/lab');\n\n\nconst { before, describe, it } = "
},
{
"path": "test/index.js",
"chars": 2601,
"preview": "'use strict';\n\nconst Url = require('url');\n\nconst Code = require('@hapi/code');\nconst Hapi = require('@hapi/hapi');\ncons"
},
{
"path": "test/listener.js",
"chars": 40620,
"preview": "'use strict';\n\nconst Url = require('url');\n\nconst Boom = require('@hapi/boom');\nconst Code = require('@hapi/code');\ncons"
},
{
"path": "test/socket.js",
"chars": 34913,
"preview": "'use strict';\n\nconst Url = require('url');\n\nconst Boom = require('@hapi/boom');\nconst Code = require('@hapi/code');\ncons"
},
{
"path": "test/types/client.ts",
"chars": 1115,
"preview": "import { types as lab } from '@hapi/lab';\nimport { expect } from '@hapi/code';\n\nconst { expect: check } = lab;\n\nimport {"
},
{
"path": "test/types/server.ts",
"chars": 2545,
"preview": "import { types as lab } from '@hapi/lab';\n\nconst { expect: check } = lab;\n\nimport * as Hapi from '@hapi/hapi';\nimport { "
}
]
About this extraction
This page contains the full source code of the hapijs/nes GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 21 files (318.7 KB), approximately 67.3k tokens, and a symbol index with 22 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.