diff --git a/.github/workflows/ci-plugin.yml b/.github/workflows/ci-plugin.yml index aa80048..24d0ea4 100644 --- a/.github/workflows/ci-plugin.yml +++ b/.github/workflows/ci-plugin.yml @@ -4,12 +4,10 @@ on: push: branches: - master + - next pull_request: workflow_dispatch: jobs: test: - uses: hapijs/.github/.github/workflows/ci-plugin.yml@master - with: - min-node-version: 14 - min-hapi-version: 20 + uses: hapijs/.github/.github/workflows/ci-plugin.yml@min-node-18-hapi-21 diff --git a/lib/client.d.ts b/lib/client.d.ts new file mode 100644 index 0000000..76a622c --- /dev/null +++ b/lib/client.d.ts @@ -0,0 +1,372 @@ +// Same as exported type in @hapi/hapi v20 +type HTTP_METHODS = 'ACL' | 'BIND' | 'CHECKOUT' | 'CONNECT' | 'COPY' | 'DELETE' | 'GET' | 'HEAD' | 'LINK' | 'LOCK' | + 'M-SEARCH' | 'MERGE' | 'MKACTIVITY' | 'MKCALENDAR' | 'MKCOL' | 'MOVE' | 'NOTIFY' | 'OPTIONS' | 'PATCH' | 'POST' | + 'PROPFIND' | 'PROPPATCH' | 'PURGE' | 'PUT' | 'REBIND' | 'REPORT' | 'SEARCH' | 'SOURCE' | 'SUBSCRIBE' | 'TRACE' | + 'UNBIND' | 'UNLINK' | 'UNLOCK' | 'UNSUBSCRIBE'; + +type ErrorType = ( + 'timeout' | + 'disconnect' | + 'server' | + 'protocol' | + 'ws' | + 'user' +); + +type ErrorCodes = { + 1000: 'Normal closure', + 1001: 'Going away', + 1002: 'Protocol error', + 1003: 'Unsupported data', + 1004: 'Reserved', + 1005: 'No status received', + 1006: 'Abnormal closure', + 1007: 'Invalid frame payload data', + 1008: 'Policy violation', + 1009: 'Message too big', + 1010: 'Mandatory extension', + 1011: 'Internal server error', + 1015: 'TLS handshake' +}; + +type NesLog = { + + readonly code: keyof ErrorCodes; + readonly explanation: ErrorCodes[keyof ErrorCodes] | 'Unknown'; + readonly reason: string; + readonly wasClean: boolean; + readonly willReconnect: boolean; + readonly wasRequested: boolean; +} + +export interface NesError extends Error { + + type: ErrorType; + isNes: true; + statusCode?: number; + data?: any; + headers?: Record; + path?: string; +} + +export interface ClientConnectOptions { + + /** + * sets the credentials used to authenticate. + * when the server is configured for + * + * - `'token'` type authentication, the value + * is the token response received from the + * authentication endpoint (called manually by + * the application). When the server is + * configured for `'direct'` type + * authentication, the value is the credentials + * expected by the server for the specified + * authentication strategy used which typically + * means an object with headers + * (e.g. `{ headers: { authorization: 'Basic am9objpzZWNyZXQ=' } }`). + */ + auth?: string | { + headers?: Record; + payload?: Record; + }; + + /** + * A boolean that indicates whether the client + * should try to reconnect. Defaults to `true`. + */ + reconnect?: boolean; + + /** + * Time in milliseconds to wait between each + * reconnection attempt. The delay time is + * cumulative, meaning that if the value is set + * to `1000` (1 second), the first wait will be + * 1 seconds, then 2 seconds, 3 seconds, until + * the `maxDelay` value is reached and then + * `maxDelay` is used. + */ + delay?: number; + + /** + * The maximum delay time in milliseconds + * between reconnections. + */ + maxDelay?: number; + + /** + * number of reconnection attempts. Defaults to + * `Infinity` (unlimited). + */ + retries?: number; + + /** + * socket connection timeout in milliseconds. + * Defaults to the WebSocket implementation + * timeout default. + */ + timeout?: number; +} + +type NesReqRes = { + payload: R; + statusCode: number; + headers: Record; +} + +export interface ClientRequestOptions { + + /** + * The requested endpoint path or route id. + */ + path: string; + + /** + * The requested HTTP method (can also be any + * method string supported by the server). + * Defaults to `'GET'`. + */ + method?: Omit, 'HEAD' | 'head'>; + /** + * An object where each key is a request header + * and the value the header content. Cannot + * include an Authorization header. Defaults to + * no headers. + */ + headers?: Record; + + /** + * The request payload sent to the server. + */ + payload?: any; +} + +export interface NesSubHandler { + + ( + message: unknown, + flags: { + + /** + * Set to `true` when the message is the + * last update from the server due to a + * subscription revocation. + */ + revoked?: boolean; + + } + ): void; +} + +/** + * Creates a new client object + * + * https://github.com/hapijs/nes/blob/master/API.md#client-5 + */ +export class Client { + + /** + * https://github.com/hapijs/nes/blob/master/API.md#new-clienturl-options + * @param url + * @param options + */ + constructor( + url: `ws://${string}` | `wss://${string}`, + options?: { + ws?: string | string[]; + timeout?: number | boolean; + }); + + /** + * The unique socket identifier assigned by the + * server. The value is set after the + * connection is established. + */ + readonly id: string | null; + + /** + * A property set by the developer to handle + * errors. Invoked whenever an error happens + * that cannot be associated with a pending + * request. + * + * https://github.com/hapijs/nes/blob/master/API.md#clientonerror + */ + onError(err: NesError): void; + + /** + * A property set by the developer used to set + * a handler for connection events (initial + * connection and subsequent reconnections) + * + * https://github.com/hapijs/nes/blob/master/API.md#clientonconnect + */ + onConnect(): void; + + /** + * A property set by the developer used to set + * a handler for disconnection events + * + * https://github.com/hapijs/nes/blob/master/API.md#clientondisconnect + * + * @param willReconnect A boolean indicating if + * the client will automatically attempt to + * reconnect + * @param log A log object containing + * information about the disconnection + */ + onDisconnect(willReconnect: boolean, log: NesLog): void; + + /** + * A property set by the developer used to set + * a handler for heartbeat timeout events + * + * https://github.com/hapijs/nes/blob/master/API.md#clientonheartbeattimeout + * + * @param willReconnect A boolean indicating if + * the client will automatically attempt to + * reconnect + */ + onHeartbeatTimeout(willReconnect: boolean): void; + + /** + * A property set by the developer used to set + * a custom message handler. Invoked whenever + * the server calls `server.broadcast()` or + * `socket.send()`. + * + * https://github.com/hapijs/nes/blob/master/API.md#clientonupdate + * + * @param message + */ + + onUpdate(message: unknown): void; + + + /** + * Connects the client to the server + * + * https://github.com/hapijs/nes/blob/master/API.md#await-clientconnectoptions + */ + connect(options?: ClientConnectOptions): Promise; + + /** + * Disconnects the client from the server and + * stops future reconnects. + * + * https://github.com/hapijs/nes/blob/master/API.md#await-clientdisconnect + */ + disconnect(): Promise; + + /** + * Sends an endpoint request to the server. + * This overload will perform a `GET` request by + * default. + * + * Rejects with `Error` if the request failed. + * + * https://github.com/hapijs/nes/blob/master/API.md#await-clientrequestoptions + * + * @param path The endpoint path + */ + request (path: string): Promise>; + + /** + * Sends an endpoint request to the server. + * + * Rejects with `Error` if the request failed. + * + * https://github.com/hapijs/nes/blob/master/API.md#await-clientrequestoptions + * + * @param options The request options + */ + request (options: ClientRequestOptions): Promise>; + + + /** + * Sends a custom message to the server which + * is received by the server `onMessage` handler + * + * https://github.com/hapijs/nes/blob/master/API.md#await-clientmessagemessage + * + * @param message The message sent to the + * server. Can be any type which can be safely + * converted to string using `JSON.stringify()`. + */ + message (message: unknown): Promise; + + /** + * Subscribes to a server subscription + * + * https://github.com/hapijs/nes/blob/master/API.md#await-clientsubscribepath-handler + * + * @param path The requested subscription path. + * Paths are just like HTTP request paths (e.g. + * `'/item/5'` or `'/updates'` based on the + * paths supported by the server). + * + * @param handler The function used to receive subscription updates + */ + subscribe(path: string, handler: NesSubHandler): Promise; + + /** + * Cancels a subscription + * + * https://github.com/hapijs/nes/blob/master/API.md#await-clientunsubscribepath-handler + * + * @param path the subscription path used to subscribe + * @param handler remove a specific handler from a + * subscription or `null` to remove all handlers for + * the given path + */ + unsubscribe(path: string, handler?: NesSubHandler): Promise; + + /** + * Returns an array of the current subscription paths. + * + * https://github.com/hapijs/nes/blob/master/API.md#clientsubscriptions + * + */ + subscriptions(): string[]; + + /** + * Sets or overrides the authentication credentials used + * to reconnect the client on disconnect when the client + * is configured to automatically reconnect + * + * Returns `true` if reconnection is enabled, otherwise + * `false` (in which case the method was ignored). + * + * Note: this will not update the credentials on the + * server - use `client.reauthenticate()` + * + * https://github.com/hapijs/nes/blob/master/API.md#clientoverridereconnectionauthauth + * + * @param auth same as the `auth` option passed to + * `client.connect()` + */ + overrideReconnectionAuth(auth: ClientConnectOptions['auth']): boolean; + + /** + * Will issue the `reauth` message to the server with + * updated `auth` details and also override the + * reconnection information, if reconnection is enabled. + * The server will respond with an error and drop the + * connection in case the new `auth` credentials are + * invalid. + * + * Rejects with `Error` if the request failed. + * + * Resolves with `true` if the request succeeds. + * + * Note: when authentication has a limited lifetime, + * `reauthenticate()` should be called early enough to + * avoid the server dropping the connection. + * + * https://github.com/hapijs/nes/blob/master/API.md#await-clientreauthenticateauth + * + * @param auth same as the `auth` option passed to + * `client.connect()` + */ + reauthenticate(auth: ClientConnectOptions['auth']): Promise; +} \ No newline at end of file diff --git a/lib/client.js b/lib/client.js index 93840eb..a84adfa 100755 --- a/lib/client.js +++ b/lib/client.js @@ -104,7 +104,7 @@ options = options || {}; - this._isBrowser = typeof WebSocket !== 'undefined'; + this._isBrowser = Client.isBrowser(); if (!this._isBrowser) { options.ws = options.ws || {}; @@ -148,6 +148,13 @@ 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 || {}; diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 0000000..73d0706 --- /dev/null +++ b/lib/index.d.ts @@ -0,0 +1,450 @@ +import * as Hapi from '@hapi/hapi'; + +import * as Iron from '@hapi/iron'; +import { Client } from './client'; + +import { + ClientConnectOptions, + ClientRequestOptions, + ErrorCodes, + ErrorType, + NesError, + NesLog, + NesReqRes, + NesSubHandler +} from './client'; + +export namespace Nes { + + export { + ClientConnectOptions, + ClientRequestOptions, + ErrorCodes, + ErrorType, + NesError, + NesLog, + NesReqRes, + NesSubHandler + } + + export interface SocketAuthObject< + U extends object = Hapi.UserCredentials, + A extends object = Hapi.AuthCredentials, + > { + isAuthenticated: boolean; + credentials: Hapi.AuthCredentials | null; + artifacts: Hapi.AuthArtifacts | null; + } + + export interface Socket< + App extends object = {}, + Auth extends SocketAuthObject = SocketAuthObject + > { + id: string, + app: App, + auth: Auth, + info: { + remoteAddress: string, + remotePort: number, + 'x-forwarded-for'?: string, + } + server: Hapi.Server, + disconnect(): Promise, + send(message: unknown): Promise, + publish(path: string, message: unknown): Promise, + revoke( + path: string, + message?: unknown | null, + options?: { + ignoreClose?: boolean, + } + ): Promise, + isOpen(): boolean, + } + + export interface ClientOpts { + onDisconnect?: ( + willReconnect: boolean, + log: { + code: number, + explanation: string, + reason: string, + wasClean: string, + willReconnect: boolean, + wasRequested: boolean, + } + ) => void + } + + export interface BroadcastOptions { + + /** + * Optional user filter. When provided, the + * message will be sent only to + * authenticated sockets with + * `credentials.user` equal to `user`. + * Requires the `auth.index` options to be + * configured to `true`. + */ + user?: string + } + + type FilterReturn = ( + boolean | { + /** + * an override `message` to send to this `socket` + * instead of the published one. Note that if you + * want to modify `message`, you must clone it first + * or the changes will apply to all other sockets. + */ + override: unknown + } + ) + + export interface SubscriptionOptions> { + /** + * Publishing filter function for making per-client + * connection decisions about which matching publication + * update should be sent to which client. + * @param path The path of the published update. The path + * is provided in case the subscription contains path + * parameters + * @param message The `JSON.stringify()` compliant + * message being published + * @param options Additional information about the + * subscription and client + * @returns + */ + filter?: ( + path: string, + message: unknown, + options: { + socket: S, + credentials?: S['auth']['credentials'], + + /** + * The parameters parsed from the publish message + * path if the subscription path contains + * parameters. + */ + params?: unknown, + + /** + * The internal options data passed to the + * `server.publish()` call, if defined. + */ + internal: unknown + }, + ) => (FilterReturn | Promise), + + /** + * A method called when a client subscribes to this + * subscription endpoint + * + * @param socket The `Socket` object of incoming + * connection + * @param path The path the client subscribed to + * @param params The parameters parsed from the + * subscription request path if the subscription path + * definition contains parameters. + + * @returns + */ + onSubscribe?: ( + socket: Socket, + path: string, + params?: unknown + ) => void, + + /** + * A method called when a client unsubscribes from this subscription endpoint + * @param socket The `Socket` object of incoming + * connection + * @param path The path the client subscribed to + * @param params The parameters parsed from the + * subscription request path if the subscription path + * definition contains parameters. + * @returns + */ + onUnsubscribe?: ( + socket: Socket, + path: string, + params?: unknown + ) => void, + + /** + * The subscription authentication options + */ + auth?: boolean | { + /** + * Same as the ***hapi*** auth modes. + */ + mode?: 'required' | 'optional', + + /** + * Same as the ***hapi*** auth scopes. + */ + scope?: string | string[], + + /** + * Same as the ***hapi*** auth entities. + */ + entity?: 'user' | 'app' | 'any', + + /** + * if `true`, authenticated socket with `user` + * property in `credentials` are mapped for usage + * in `server.publish()` calls. Defaults to `false`. + */ + index?: boolean, + } + } + + export interface PublishOptions { + /** + * Optional user filter. When provided, the message will + * be sent only to authenticated sockets with + * `credentials.user` equal to `user`. Requires the + * subscription `auth.index` options to be configured to + * `true`. + */ + user?: string, + + /** + * Internal data that is passed to `filter` and may be + * used to filter messages on data that is not sent to + * the client. + */ + internal?: unknown + } + + export interface EachSocketOptions { + /** + * When set to a string path, limits the results to sockets that are subscribed to that path. + */ + subscription?: string, + + /** + * Optional user filter. When provided, the `each` method + * will be invoked with authenticated sockets with + * `credentials.user` equal to `user`. Requires the + * subscription `auth.index` options to be configured to + * `true`. + */ + user?: string + + } + + /** + * Plugin options + * + * https://github.com/hapijs/nes/blob/master/API.md#registration + */ + export interface PluginOptions< + App extends object = {}, + Auth extends SocketAuthObject = SocketAuthObject + > { + /** + * A function invoked for each incoming connection + * @param socket The `Socket` object of incoming + * connection + */ + onConnection?: (socket: Socket) => void + + /** + * A function invoked for each disconnection + * @param socket The `Socket` object of incoming + * connection + */ + onDisconnection?: (socket: Socket) => void + + /** + * A function used to receive custom client messages + * @param message The message sent by the client + * @returns + */ + onMessage?: ( + socket: Socket, + message: unknown + ) => void + + /** + * Optional plugin authentication options. The details of + * this object do imply quiet a bit of logic, so it is + * best to see the documentation for more information. + * + * https://github.com/hapijs/nes/blob/master/API.md#registration + */ + auth?: false | { + endpoint?: string + id?: string + type?: 'cookie' | 'token' | 'direct', + route?: Hapi.RouteOptions['auth'], + cookie?: string, + isSecure?: boolean, + isHttpOnly?: boolean, + isSameSite?: 'Strict' | 'Lax' | false, + path?: string | null, + domain?: string | null, + ttl?: number | null, + iron?: Iron.SealOptions, + password?: Iron.Password | Iron.password.Secret, + index?: boolean, + timeout?: number | false, + maxConnectionsPerUser?: number | false, + minAuthVerifyInterval?: number | false, + }, + + /** + * An optional array of header field names to include in + * server responses to the client. If set to `'*'` + * (without an array), allows all headers. Defaults to + * `null` (no headers). + */ + headers?: string[] | '*' | null, + + /** + * Optional message payload + */ + payload?: { + + /** + * the maximum number of characters (after the full + * protocol object is converted to a string using + * `JSON.stringify()`) allowed in a single WebSocket + * message. This is important when using the protocol + * over a slow network (e.g. mobile) with large + * updates as the transmission time can exceed the + * timeout or heartbeat limits which will cause the + * client to disconnect. Defaults to `false` + * (no limit). + */ + maxChunkChars?: number | false, + }, + + /** + * Configures connection keep-alive settings. + * When set to `false`, the server will not send + * heartbeats. Defaults to: + * + * ```js + * { + * interval: 15000, + * timeout: 5000 + * } + * ``` + */ + heartbeat?: false | { + + /** + * The time interval between heartbeat messages in + * milliseconds. Defaults to `15000` (15 seconds). + */ + interval: number, + + /** + * timeout in milliseconds after a heartbeat is sent + * to the client and before the client is considered + * disconnected by the server. Defaults to `5000` + * (5 seconds). + */ + timeout?: number, + }, + + /** + * If specified, limits the number of simultaneous client + * connections. Defaults to `false`. + */ + maxConnections?: number | false, + + /** + * An origin string or an array of origin strings + * incoming client requests must match for the connection + * to be permitted. Defaults to no origin validation. + */ + origins?: string | string[] + } +} + +export { Client } + +export const plugin: Hapi.Plugin; + + +declare module '@hapi/hapi' { + + interface Server { + + /** + * Sends a message to all connected clients + * where: + * + * https://hapi.dev/module/nes/api/?v=13.0.1#await-serverbroadcastmessage-options + * + * @param message The message sent to the + * clients. Can be any type which can be + * safely converted to string using `JSON. + * stringify()`. + * @param options An optional object + */ + broadcast(message: unknown, options?: Nes.BroadcastOptions): void; + + /** + * Declares a subscription path client can + * subscribe to where: + * + * https://hapi.dev/module/nes/api/?v=13.0.1#serversubscriptionpath-options + * + * @param path An HTTP-like path. The path + * must begin with the `'/'` character. The + * path may contain path parameters as + * supported by the ***hapi*** route path parser. + + * @param options An optional object + */ + subscription(path: string, options?: Nes.SubscriptionOptions): void; + + /** + * Sends a message to all the subscribed clients + * + * https://github.com/hapijs/nes/blob/master/API.md#await-serverpublishpath-message-options + * + * @param path the subscription path. The path is matched + * first against the available subscriptions added via + * `server.subscription()` and then against the specific + * path provided by each client at the time of + * registration (only matter when the subscription path + * contains parameters). When a match is found, the + * subscription `filter` function is called (if present) + * to further filter which client should receive which + * update. + * + * @param message The message sent to the clients. Can be any type which can be safely converted to string using `JSON.stringify()`. + * @param options optional object + */ + publish(path: string, message: unknown, options?: Nes.PublishOptions): void; + + /** + * Iterates over all connected sockets, optionally + * filtering on those that have subscribed to a given + * subscription. This operation is synchronous + * + * @param each Iteration method + * @param options Optional options + */ + eachSocket( + each: (socket: Nes.Socket) => void, + options?: Nes.EachSocketOptions + ): void; + } + + interface Request { + + /** + * Provides access to the `Socket` object of the incoming + * connection + */ + socket: Nes.Socket; + } +} + diff --git a/lib/listener.js b/lib/listener.js index 71f73a3..b1ca711 100755 --- a/lib/listener.js +++ b/lib/listener.js @@ -33,6 +33,7 @@ exports = module.exports = internals.Listener = function (server, settings) { // WebSocket listener const options = { server: this._server.listener }; + if (settings.origin) { options.verifyClient = (info) => settings.origin.indexOf(info.origin) >= 0; } @@ -103,6 +104,10 @@ internals.Listener.prototype._close = async function () { 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(); + } }; diff --git a/package.json b/package.json index 681b433..584136b 100755 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "version": "13.0.1", "repository": "git://github.com/hapijs/nes", "main": "lib/index.js", + "types": "lib/index.d.ts", + "engines": { + "node": ">=18.0.0" + }, "files": [ "lib" ], @@ -26,16 +30,19 @@ "@hapi/iron": "^7.0.1", "@hapi/teamwork": "^6.0.0", "@hapi/validate": "^2.0.1", - "ws": "^7.3.1" + "ws": "^8.17.1" }, "devDependencies": { "@hapi/code": "^9.0.3", "@hapi/eslint-plugin": "*", - "@hapi/hapi": "^21.2.1", - "@hapi/lab": "^25.1.2" + "@hapi/hapi": "^21.3.9", + "@hapi/lab": "^25.2.0", + "@types/node": "^20.14.2", + "joi": "^17.13.3", + "typescript": "^5.4.5" }, "scripts": { - "test": "lab -a @hapi/code -t 100 -L -m 10000", + "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" diff --git a/test/auth.js b/test/auth.js index be12efe..3b62fbe 100755 --- a/test/auth.js +++ b/test/auth.js @@ -1328,7 +1328,7 @@ describe('authentication', () => { await server.stop(); }); - it.skip('disconnects the client after authentication expires', async () => { + it('disconnects the client after authentication expires', async () => { const server = Hapi.server(); @@ -1438,7 +1438,7 @@ describe('authentication', () => { expect(server.plugins.nes._listener._settings.auth.minAuthVerifyInterval).to.equal(15000); }); - it.skip('uses updated authentication information when verifying', async () => { + it('uses updated authentication information when verifying', async () => { const server = Hapi.server(); diff --git a/test/client.js b/test/client.js index 5f5624a..7b58d57 100755 --- a/test/client.js +++ b/test/client.js @@ -2,6 +2,7 @@ const Url = require('url'); +const Somever = require('@hapi/somever'); const Boom = require('@hapi/boom'); const Code = require('@hapi/code'); const Hapi = require('@hapi/hapi'); @@ -36,17 +37,19 @@ describe('Client', () => { it('ignores options.ws in browser', async (flags) => { - const orig = global.WebSocket; + 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 (orig) { - global.WebSocket = orig; + if (origWebSocket) { + global.WebSocket = origWebSocket; } else { delete global.WebSocket; @@ -54,9 +57,13 @@ describe('Client', () => { 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(); @@ -704,7 +711,7 @@ describe('Client', () => { await server.stop(); }); - it.skip('overrides max delay', { retry: true }, async () => { + it('overrides max delay', { retry: true }, async () => { const server = Hapi.server(); await server.register({ plugin: Nes, options: { auth: false } }); @@ -1200,7 +1207,19 @@ describe('Client', () => { await client.connect(); await client.request('/'); - expect(logged.message).to.match(/Unexpected end of(?: JSON)? input/); + + + const nodeGte20 = Somever.range().above('19').match(process.versions.node); + + let expectMsg = /Unexpected end of(?: JSON)? input/; + + if (nodeGte20) { + + expectMsg = /Expected property name .+JSON.+/; + } + + + expect(logged.message).to.match(expectMsg); expect(logged.type).to.equal('protocol'); expect(logged.isNes).to.equal(true); diff --git a/test/listener.js b/test/listener.js index 891abde..cd98ef3 100755 --- a/test/listener.js +++ b/test/listener.js @@ -215,7 +215,7 @@ describe('Listener', () => { await server.stop(); }); - it.skip('does not disconnect newly connecting sockets', async () => { + it('does not disconnect newly connecting sockets', async () => { const server = Hapi.server(); let disconnected = 0; diff --git a/test/types/client.ts b/test/types/client.ts new file mode 100644 index 0000000..d8bdf77 --- /dev/null +++ b/test/types/client.ts @@ -0,0 +1,41 @@ +import { types as lab } from '@hapi/lab'; +import { expect } from '@hapi/code'; + +const { expect: check } = lab; + +import { Client } from '../../lib/client'; + +const init = () => { + + const client = new Client('ws://localhost'); + + client.connect() + + client.connect({ + auth: { + headers: { + authorization: 'Basic am9objpzZWNyZXQ=' + } + } + }); + + client.request('hello'); + + client.reauthenticate({ + headers: { + authorization: 'Bearer am9objpzZWNyZXQ=' + } + }); + + client.onConnect = () => console.log('connected'); + client.onDisconnect = (willReconnect) => console.log('disconnected', willReconnect); + client.onError = (err) => console.error(err); + client.onUpdate = (update) => console.log(update); + + client.connect(); + + client.subscribe('/item/5', (update) => console.log(update)); + client.unsubscribe('/item/5'); + + client.disconnect(); +} diff --git a/test/types/server.ts b/test/types/server.ts new file mode 100644 index 0000000..7f1f23f --- /dev/null +++ b/test/types/server.ts @@ -0,0 +1,96 @@ +import { types as lab } from '@hapi/lab'; + +const { expect: check } = lab; + +import * as Hapi from '@hapi/hapi'; +import { Plugin, ServerRegisterPluginObjectDirect } from '@hapi/hapi'; + +import * as NesPlugin from '../../lib'; +import { Nes, Client, plugin } from '../../lib'; + +const init = async () => { + + const server = Hapi.server(); + + await server.register(NesPlugin); + + const nesPlugin: ServerRegisterPluginObjectDirect = { + plugin, + options: { + auth: { + cookie: 'wee', + endpoint: '/hello', + id: 'hello', + route: 'woo', + type: 'cookie', + domain: '', + index: true, + iron: { + encryption: { + algorithm: 'aes-128-ctr', + iterations: 4, + minPasswordlength: 8, + saltBits: 16 + }, + integrity: { + + algorithm: 'aes-128-ctr', + iterations: 4, + minPasswordlength: 8, + saltBits: 16 + }, + localtimeOffsetMsec: 10 * 1000, + timestampSkewSec: 10 * 1000, + ttl: 10 * 1000 + } + }, + async onMessage(socket, _message) { + + const message = _message as { test: true }; + + if (message.test === true) { + + await socket.send({ hey: 'man' }) + } + }, + } + } + + await server.register(nesPlugin); + + check.type>(NesPlugin.plugin); + + server.subscription('/item/{id}'); + server.broadcast('welcome'); + + server.route({ + method: 'GET', + path: '/test', + handler: (request) => { + + check.type(request.socket); + + return { + test: 'passes ' + request.socket.id + }; + } + }); + + server.publish('/item/5', { id: 5, status: 'complete' }); + server.publish('/item/6', { id: 6, status: 'initial' }); + + const socket: Nes.Socket = {} as any; + + socket.send('message'); + socket.publish('path', 'message'); + socket.revoke('path', 'message'); + socket.disconnect(); + + check.type< + (p: string, m: unknown, o: Nes.PublishOptions) => void + >(server.publish); + + const client = new Client('ws://localhost'); + + client.connect(); +}; \ No newline at end of file