# WebSocket client for browsers and Node.js

This is a WebSocket wrapper that aims to fix some common annoyances we delt with
when building our frontend SDKs:

**Native WebSocket:** API is events-based. You wait for the `open` event to
ensure a connection is open, and for `error` event to handle errors.

**This library:** API is Promise-based: you `await ws.open()`, and wrap it with
try/catch to handle errors.

---

**Native WebSocket:** instances are single-use. Connection starts as soon as the
instance is created. After connection closes, a new instance must be created to
reconnect.

**This library:** instances can be reused: you can `ws.open()` and `ws.close()`
multiple times, without reinstalling event handlers. Great for using WebSockets
from UI components.

---

**Native WebSocket:** with an events-based API, business logic is expressed with
callbacks and state variables.

**This library:** business logic can be written imperatively with
[procedures](#executing-procedures).

## Installation

```sh
npm install @stream-io/ws
yarn add @stream-io/ws
pnpm add @stream-io/ws
```

## Basics

### Opening a connection

Create a client instance and open a connection:

```ts
import { WebSocketClient } from "@stream-io/ws";
const ws = new WebSocketClient({ url: "wss://example.com" });
await ws.open();
```

After `ws.open()` resolves, the connection is ready.

This method can reject if the URL is malformed, the server is unreachable,
refuses to upgrade the connection, goes away or closes the connection before
it's ready.

Unlike the native WebSocket, `WebSocketClient` doesn't dispatch the `open` event
when the connection is ready, or `error` event in case of problems. Await
`ws.open()` instead, and wrap it with try/catch to handle errors.

### Sending and receiving messages

Send a message with `ws.send()`:

```ts
const ws = new WebSocketClient({ url: "wss://example.com" });
await ws.open();
ws.send({ type: "hello", name: "world" });
```

Make sure you've awaited `ws.open()` before sending a message.

By default, message payload is serialized as JSON. If you want to just send
UTF-8 strings, provide a text `format` when creating the client:

```ts
import { WebSocketClient, textFormat } from "@stream-io/ws";
const ws = new WebSocketClient({
  url: "wss://example.com",
  format: textFormat(),
});
await ws.open();
ws.send("Hello, world!");
```

You might already have types for the JSON message payloads. To have your
messages strongly typed, use `jsonFormat()` with a type argument:

```ts
import { WebSocketClient, jsonFormat } from "@stream-io/ws";

type MessagePayload = { type: "hello"; name: string } | { type: "goodbye" };

const ws = new WebSocketClient({
  url: "wss://example.com",
  format: jsonFormat<MessagePayload>(),
});
await ws.open();
ws.send({ type: "hello", name: "world" });
```

Strong typing is not the same as validation. Passing a type argument doesn't
have any runtime effect. To validate payloads, implement a
[custom message format](#custom-message-formats).

To receive a message, listen for the `message` event:

```ts
const ws = new WebSocketClient({
  url: "wss://example.com",
  format: textFormat(),
});
await ws.open();
ws.on("message", (message) => {
  console.log(message);
});
```

Install `message` event handlers before or _synchroniously_ after `ws.open()`,
otherwise some messages can be missed.

The `message` event is only dispatched on the current connection. Although it's
technically possible to receive a message after `ws.close()` was called, the
client will not dispatch these events.

If a message payload cannot be parsed (e.g. you're using `jsonFormat()` and the
message is not a valid JSON stirng), the `recverror` event is dispatched.
Listening to this event can be useful for logging, and it's also an opportunity
to close the faulty connection:

```ts
const ws = new WebSocketClient({
  url: "wss://example.com",
  format: jsonFormat(),
});
await ws.open();
ws.on("recverror", (err) => {
  console.error(err);
  ws.close(4000, "unparseable payload");
});
```

### Closing connection

Close a connection with `ws.close()`:

```ts
const ws = new WebSocketClient({ url: "wss://example.com" });
await ws.open();
await ws.close();
```

You can also specify a reason for closure. Be sure to only use status codes in
the 3000-4999 range, and only use the 4000-4999 range for application-specific
reasons:

```ts
await ws.close(4018, "I'm a teapot");
```

No `close` event is dispatched when you close the connection this way. Instead,
the connection is fully closed after `ws.close()` resolves.

This method never rejects, and it's usually not necessary to await it. New
connection can be opened immediately after calling `ws.close()`:

```ts
const ws = new WebSocketClient({ url: "wss://example.com" });
await ws.open();
ws.close();
await ws.open();
```

This is useful when using WebSockets from something like a React component. This
code doesn't have problems running in `<StrictMode />`:

```ts
const ws = useMemo(() => new WebSocketClient({ url }), [url]);
const [isReady, setReady] = useState(false);
useEffect(() => {
  ws.open().then(() => setReady(true));
  return () => {
    setReady(false);
    ws.close();
  };
}, [ws]);
```

Calling `ws.close()` interrupts any current async operations. For example, if
it's called before the connection is ready, the `ws.open()` method rejects with
an `AbortedError`:

```ts
import { WebSocketClient, AbortedError } from "@stream-io/ws";
const ws = new WebSocketClient({ url: "wss://example.com" });
ws.open().catch((err) => {
  if (err instanceof AbortedError) {
    console.log("Connection cancelled");
  }
});
ws.close();
```

It's not possible to end up in a race condition between `ws.open()` and
`ws.close()` calls. The latest call determines the state of the connection.

### Handling connection closure

Connection be closed without an explicit `ws.close()` call:

1. connection closure can be initiated server-side;
2. server can go offline abruptly;
3. network distruption can cause the connection to be dropped; etc.

We say that in these cases the connection is closed _externally,_ and the client
dispatches the `close` event:

```ts
const ws = new WebSocketClient({ url: "wss://example.com" });
await ws.open();
ws.on("close", (code, reason) => {
  console.log("Connection closed with code", code, "and reason", reason);
});
```

The `close` event is only dispatched if the connection was ready. If the
connection closes before it's ready, `ws.open()` rejects with a
`WebSocketClosedError`, which may contain a status code and reason for closure
(although these are rarely useful in case of abrupt closure):

```ts
import { WebSocketClient, WebSocketClosedError } from "@stream-io/ws";
const ws = new WebSocketClient({ url: "wss://example.com" });

try {
  await ws.open();
} catch (err) {
  if (err instanceof WebSocketClosedError) {
    console.log("Connection closed abruptly");
    if (err.code && err.reason) {
      console.log("Code:", err.code);
      console.log("Reason:", err.reason);
    }
  }
}
```

### Executing procedures

Procedures are a way of writing your WebSocket-based business logic without
dealing with event handlers and state variables.

Say, for example, you expect your server to acknowledge message delivery. Here's
a vanilla WebSocket implementation:

```ts
function sendAndAck(ws: WebSocket, message: string): Promise<void> {
  const { promise, resolve } = Promise.withResolvers<void>();
  const handleMessage = (event: MessageEvent) => {
    if (message === `ack:${message}`) {
      ws.removeEventListener("message", handleMessage);
      resolve();
    }
  };
  ws.addEventListeners("message", handleMessage);
  ws.send(message);
  return promise;
}
```

The logic is split between the function and the event handler. It's also easy to
have a memory leak if you're not careful about cleaning up event listeners.

Here's the same logic implemented as a procedure:

```ts
function sendAndAck(ws: WebSocketClient<string>, message: string): Promise<void> {
  return ws.exec(function* ({ send, recv }) {
    yield send(message);
    while (yield recv() !== `ack:${message}`) {}
  });
}
```

Procedure is a function that gives the client commands to perform actions or
check certain conditions. Procedures are
[generators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*)
that are executed with `ws.exec()`.

Make sure you've awaited `ws.open()` before executing a procedure.

Supported commands are:

1. `send(message)` instructs the client to send a message;
2. `recv()` instructs the client to wait for an incoming message, and returns
   the message payload;
3. `expect(predicate)` checks the next incoming message against the predicate
   and throws `UnexpectedMessageError` if the check fails.
4. `settle(promise)` awaits a promise. This comes with some caveats, see
   [below](#async-actions-in-procedures).

These commands are passed to your generator as the only argument. The generator
must `yield` the command for it to be executed.

The `ws.exec()` promise resolves with the return value of the generator.

Procedures are great for turning event-based logic into imperative logic. They
allow you to write code in a straightforward, linear manner, and avoid storing
state.

Here's a sample client that asks the server for a password and checks the
response:

```ts
import { WebSocketClient, UnexpectedMessageError, textFormat } from "@stream-io/ws";

const ws = new WebSocketClient({
  url: "wss://example.com",
  format: textFormat(),
});
await ws.open();
const superSecretPassword = "p@$$w0rd";

try {
  await ws.exec(function* ({ send, expect }) {
    yield send("password?");
    yield expect((res) => res === superSecretPassword);
  });
} catch (err) {
  if (err instanceof UnexpectedMessageError) {
    ws.close(4001, "wrong password");
  }
}
```

Another sample client echoes everything the server sends, until it receives the
message saying "stop":

```ts
await ws.exec(function* ({ send, recv }) {
  while (true) {
    const message = yield recv();
    if (message === "stop") {
      return;
    }
    yield send(message);
  }
});
```

Procedures are interrupted by explicit `ws.close()` calls. In that case,
`ws.exec()` rejects with an `AbortedError`.

Procedures are also interrupted by external closures, in which case `ws.exec()`
rejects with a `WebSocketClosedError` containing status code and reason for
closure.

#### Async actions in procedures

Procedures can wait for async actions to complete by yielding the
`settle(promise)` command. Procedure resumes once the promise resolves.
Rejections can be caught with try/catch.

This should be used with caution:

1. Any message that arrives while waiting is missed by subsequent `recv` or
   `expect` comamnds. If the procedure is running with `suppressMessageEvents`
   option enabled, it's missed completely.
2. If the connection closes while waiting, the procedure is interrupted
   immediately, but the async action itself will keep executing.
3. The resolved value of the promise is ignored.

## Behaviors

### Resilient

In practice, a client needs to keep a healthy WebSocket connection, and to try
reconnecting if the connection is closed externally (for example, after a
network disruption).

To make the client resilient to external closures, wrap it with `makeResilient`:

```ts
import { WebSocketClient, makeResilient, exponentialBackoff } from "@stream-io/ws";
const ws = new WebSocketClient({ url: "wss://example.com" });
const resilient = makeResilient(ws, {
  retry: exponentialBackoff(),
  maxTimeoutMs: 30_000,
});
await resilient.open();
```

The `resilient.open()` method will keep trying to connect until it succeeds, or
until it can no longer retry, in which case it rejects.

External closures don't dispatch the `close` event on the resilient client.
Instead, it tries to reconnect until it succeeds, or until it can no longer
retry, in which case the `gaveup` event is dispatched.

The `resilient.healthy()` promise can be awaited to make sure the connection is
healthy. It resolves when the current reconnection attempt suceeds (or
immediately if the connection is healthy). It rejects if the client gives up
trying to reconnect.

Retrying is affected by the retry policy and the maximum timeout.

The retry policy controls how often and how many reconnection attempts can be
made. We recommend the `exponentialBackoff` policy: each subsequent attempt is
delayed by an increasing amount of time, and the total number of attempts is
configurable:

```ts
makeResilient(ws, {
  retry: exponentialBackoff({
    minDelayMs: 1000, // how long to delay the second attempt
    maxDelayMs: 10_000, // maximum delay after an attempt
    factor: 2, // multiply delay by this factor after every new attempt,
    maxAttempts: Number.POSITIVE_INFINITY, // make no more than this number of attempts
    jitter: 0.1, // add a little randomness to the delays (percentage of `minDelayMs`)
  }),
  maxTimeoutMs: 30_000,
});
```

The maximum timeout controls how long the connection can stay unhealthy before
the client gives up trying to reconnect.

When the maximum timeout is reached, or the retry policy prohibits further
attempts, the `gaveup` event is dispatched, and the connection remains closed.

Wrapping the client with `makeResilient` doesn't modify the behavior of
`ws.send()` and `ws.exec()`. These methods still throw or reject if the
connection is not opened at the moment they are called. You should either:

1. handle these errors, or
2. await the `resilient.healthy()` promise before sending a message or executing
   a procedure:

```ts
await resilient.healthy();
resilient.send("ping");
```

Note that this can delay sending a message or executing a procedure by up to the
`maxTimeoutMs`. Procedures will still be cancelled with a `WebSocketClosedError`
if the connection drops while the procedure is executing.

For logging purposes, the `reconnect` event is dispatched before the first
reconnection attempt is made:

```ts
resilient.on('reconnect', await (code, reason) => {
  console.log('Connection dropped with code', code, 'and reason', reason);
  await resilient.healthy();
  console.log('Connection restored');
})
```

### Authenticated

If you're implementing a handshake procedure between the client and the server,
wrap the `WebSocketClient` instance with `makeAuthenticated`:

```ts
import { WebSocketClient, textFormat, makeAuthenticated } from "@stream-io/ws";
const ws = new WebSocketClient({
  url: "wss://example.com",
  format: textFormat(),
});
const superSecretToken = "token";
const authenticated = makeAuthenticated(ws)(function* ({ send, expect }) {
  yield send(`token:${superSecretToken}`);
  yield expect((m) => m === "ok");
});
await authenticated.open();
```

(Note that the double parenthesis in the example above:
`makeAuthenticated(ws)(handshake)`. This is required for better type inference.)

The connection wrapped with `makeAuthenticated` is not considered ready before
the handshake is completed. If it throws an error, `authenticated.open()`
rejects with the same error.

No `message` events are dispatched by the authenticated client before the
handshake completes.

The value returned by the handshake is passed on to the `authenticated.open()`
promise:

```ts
const authenticated = makeAuthenticated(ws)(function* ({ send, recv }) {
  yield send(`token:${superSecretToken}`);
  const res = yield recv();
  return res;
});
const res = await authenticated.open();
console.log("Authenticated:", res);
```

### Health checked

It's common to check if both the client and the server are still there by
sending a periodic health check message. Failing a health check indicates that
the connection should be closed.

This behvior can be added by wrapping the client with `makeHealthChecked`:

```ts
import { WebSocketClient, textFormat, makeHealthChecked } from "@stream-io/ws";
const ws = new WebSocketClient({
  url: "wss://example.com",
  format: textFormat(),
});
const hchecked = makeHealthChecked(ws, {
  checkEveryMs: 25_000,
  timeoutMs: 10_000,
})(function* ({ send, expect }) {
  yield send("ping");
  yield expect((m) => m === "pong");
});
await hchecked.open();
```

(Note that the double parenthesis in the example above:
`makeHealthChecked(ws, options)(check)`. This is required for better type
inference.)

Every `checkEveryMs`, the health check procedure is executed. If it throws, or
fails to complete within the `timeoutMs`, the `close` event is dispatched and
the connection is closed.

### Combining behaviors

Resilient, authenticated, and health checked behaviors can be composed together.

In this example, the client performs the handshake on every reconnect. If the
handshake fails, a new connection attempt is made according to the retry policy.
While connected, it also performs health checks, and if a health check fails,
the client reconnects:

```ts
import {
  WebSocketClient,
  textFormat,
  makeAuthenticated,
  makeHealthChecked,
  makeResilient,
  exponentialBackoff,
} from "@stream-io/ws";
const ws = new WebSocketClient({
  url: "wss://example.com",
  format: textFormat(),
});
const superSecretToken = "token";
const authenticated = makeAuthenticated(ws)(function* ({ send, expect }) {
  yield send(`token:${superSecretToken}`);
  yield expect((m) => m === "ok");
});
const hchecked = makeHealthChecked(authenticated, {
  checkEveryMs: 25_000,
  timeoutMs: 10_000,
})(function* ({ send, expect }) {
  yield send("ping");
  const res = yield recv();
  return res;
});
const resilient = makeResilient(hchecked, {
  retry: exponentialBackoff(),
  maxTimeoutMs: 30_000,
});
const res = await resilient.open();
console.log("Connected. Authentication result:", res);
```

Since the `resilient.healthy()` promise resolves with the return value of
`resilient.open()`, the latest handshake result can be always accessed like
this:

```ts
const res = await resilient.healthy();
resilient.send(`hello from ${res}`);
```

This is useful if the handshake result affects how you send your messages (e.g.
messages need to be signed with a key shared during handshake).

## Advanced

### Custom message formats

Message formats provide type safety, validation and (de-)serialization
functionality when sending and receiving messages.

To support a custom message format, implement the `MessageFormat<T>` interface.
It has a formatter to serialize and a parser to deserialize message payloads.
The type argument is the payload type:

```ts
const integerFormat: MessageFormat<number> = {
  parser(data) {
    const int = Number.parseInt(data, 10);
    if (!Number.isSafeInteger(int)) {
      throw new Error("Expected message to be a valid integer string");
    }
    return int;
  },

  formatter(int) {
    return int.toString(10);
  },
};
```

Throwing an error from the parser dispatches the `recverror` event. Another
option is to return a special `recvIgnore()` value which prevents the `message`
event from dispatching. It's useful for "forgiving" parsers that ignore invalid
payloads.

The formatter must return one of the types that can be sent via WebSocket:
string, ArrayBuffer (or ArrayBufferView), Blob.

In this example messages are validated using a [Zod schema](https://zod.dev/):

```ts
import type { MessageFormat } from "@stream-io/ws";
import * as z from "zod/v4";
const MessageSchema = z.discriminatedUnion("status", [
  z.object({ status: z.literal("success"), data: z.string() }),
  z.object({ status: z.literal("error"), error: z.string() }),
]);
const format: MessageFormat<z.infer<typeof MessageSchema>> = {
  parser: (data) => MessageSchema.parse(JSON.parse(obj)),
  formatter: (obj) => JSON.stringify(obj),
};
```

### Custom retry policies

Retry policy is an async function that returns true if another attempt should be
made, or false if not. Before returning, it can implement a delay or other logic
based on the attempt number and the latest error that caused a retry.

This sample policy implements a 1 second delay after every attempt, and allows
no more than 3 attempts:

```ts
import type { Retry } from "@stream-io/ws";
const thrice: Retry = ({ attempt }) =>
  attempt < 3
    ? new Promise((resolve) => setTimeout(() => resolve(true), 1000))
    : Promise.resolve(false);
```

Note that the first time the retry policy is called, the attempt number is 1,
since the first (0th) attempt is always made unconditionally.

### Custom behaviors

Behaviors change or add functionality to the client while implementing a
compatible interface.

We recommend defining behaviors as a wrapper function that accepts and returns a
`WebSocketLike<T, R>` object, where `T` is the message payload type, and `R` is
the return type of `ws.open()`. The return type may also extend `WebSocketLike`.

For example, this is the signature of the `makeResilient` behavior:

```ts
import type { WebSocketLike } from "@stream-io/ws";
interface ResilientWebSocket<T, R = void> extends WebSocketLike<T, R> {
  healthy(): Promise<R>;
  // ...
}
function makeResilient<T, R>(
  ws: WebSocketLike<T, R>,
  options: ResilientWebSocketOptions,
): ResilientWebSocket<T, R> {
  // ...
}
```

## Reference

### WebSocketClient

This is the main class you use to create a WebSocket connection.

#### constructor(options)

Creates a client instance. Unlike the native WebSocket, connection is not
started once the client is created. Instead, you open the connection with
[`ws.open()`](#open).

`options.url` URL of the WebSocket endpoint. Usually starts with ws:// or
wss://.

`options.format` [Message format](#formats) to parse (deserialize) and format
(serialize) message payloads. Use [`textFormat()`](#textformat) to send a
receive raw UTF-8 strings, and [`jsonFormat<T>()`](#jsonformat) for JSON
strings.

This option is used to infer the message payload type for the client. It's
usually a mistake to pass the `WebSocketClient<T>` type argument explicitly.
Instead, use an appropriate message format.

#### open()

Opens a connection. You should always await `ws.open()` before sending messages
or executing procedures.

Returns: promise that resolves once the connection is ready.

If the connection closes before it's ready, this method rejects with a
[`WebSocketClosedError`](#websocketclosederror) which may contain a status code
and reason for closure (although these are rarely useful in case of an abrupt
closure).

Opening a connection can be interrupted by calling
[`ws.close()`](#closecode-reason), in which case this method rejects with an
[`AbortedError`](#abortederror).

#### close(code, reason)

Closes a connection with optionally specified status code and reason. Be sure to
only use status codes in the 3000-4999 range, and only use the 4000-4999 range
for application-specific reasons.

Returns: promise that resolves once the connection is closed.

Unlike the native WebSocket, no `close` event is dispatched as a result of
calling this method. Instead, the connection is fully closed once the returned
promise resolved.

It's not necessary to await this method. It never rejects, and
[`ws.open()`](#open) can be called again immediately after `ws.close()`.

Calling this method interrupts any running async operations, such as opening a
connection, executing a procedure, waiting for a handshake or a health check.
These async operations will reject with an `AbortedError`.

#### send(message)

Sends a message. Before sending, the message is serialized using the provided
message format. Make sure you await [`ws.open`](#open) before sending a message,
otherwise this method throws an error.

This method is synchronous, and as with the native WebSockets, the message is
queued, and its delivery is not guaranteed.

#### exec(procedure, options)

Executes a [procedure](#executing-procedures). Make sure you await
[`ws.open`](#open) before sending a message, otherwise this method rejects.

Returns: promise that resolves with the return value of the procedure.

If the connection closes before the procedure completes, this method rejects
with a [`WebSocketClosedError`](#websocketclosederror) which contains a status
code and reason for closure.

Executing the procedure can be interrupted by calling
[`ws.close()`](#closecode-reason), in which case this method rejects with an
[`AbortedError`](#abortederror).

`options.suppressMessageEvents` When true, no `message` events are dispatched
while the procedure is executing. Useful when the procedure exclusively handles
all incoming messages. Default: false.

#### on(event, cb)

Installs an event listener.

Returns: function that can be called to uninstall the listener.

```ts
const unlisten = ws.on("message", (message) => {});
unlisten();
```

All listeners are guaranteed to run once when an event is dispatched, even if
one of the event listeners throws an error. In browsers, the `error` event will
be dispatched on the `window` in case on of the listeners throws. In Node.js,
thrown errors are ignored.

`event` One of supported [events](#events).

`cb` Listener to be executed when the event is dispatched.

#### Events

`message` Dispatched on incoming messages. Before dispatching this event, the
message is deserialized using the provided message format.

Callback signature is `(message: T) => void`, `T` is message payload type as
defined by the provided message format.

This event can be suppressed in two cases:

1. the parser for the provided message format returned
   [`recvIgnore()`](#recvignore), a special value indicating that the message
   must be ignored;
2. a procedure is running with the
   [`suppressMessageEvents`](#execprocedure-options) option.

`recverror` Dispatched when the parser for the provided message format throws an
error.

Callback signature is `(error: any) => void`.

`close` Dispatched when the connection is closed _externally:_ closure was
initiated server-side, server went offline abruptly, network distruption caused
the connection to be dropped, etc.

Callback signature is `(code: number, reason: string) => void`.

Unlike the native WebSocket, closing the connection with
[`ws.close()`](#closecode-reason) does not dispatch this event.

### Formats

#### textFormat()

Pass this format to the `WebSocketClient` [constructor](#constructoroptions) to
send and receive messages as UTF-8 strings.

#### jsonFormat()

Pass this format to the `WebSocketClient` [constructor](#constructoroptions) to
send and receive as JSON strings. This format is most useful when message
payload type is known. Then it can be passed as a type parameter:

```ts
type MessagePayload = { type: "hello"; name: string } | { type: "goodbye" };
const ws = new WebSocketClient({
  url: "wss://example.com",
  format: jsonFormat<MessagePayload>(),
});
```

Strong typing is not the same as validation. Passing a type argument doesn't
have any runtime effect. To validate payloads, implement a
[custom message format](#custom-message-formats).

#### recvIgnore()

This is a special value that can be returned by a custom format that indicates
that an incoming message should be ignored. Returning `recvIgnore()` prevents
the `message` event from dispatching

#### Custom formats

Custom formats must implement the `MessageFormat<T>` interface and provide a
formatter to serialize and a parser to deserialize message payloads.

Formatter receives a message of type `T` and must return one of the types that
can be sent via WebSocket: string, ArrayBuffer (or ArrayBufferView), Blob.

Parser receives an incoming message (MessageEvent's `event.data`) and must
return parsed payload of type `T`, or a special [`recvIgnore()`](#recvignore)
value.

### Resilient

#### makeResilient(ws, options)

Wraps the provided client and makes it resilient to abrupt closures. See
[Resilient behavior](#resilient).

`ws` Client to be wrapped. It can be the `WebSocketClient` instance, or any
other `WebSocketLike` object (e.g. the client wrapped with some other behavior).

`options.retry` Retry policy to be used when reconnecting. Can be one of the
built-in policies, like [`exponentialBackoff`](#exponentialbackoffoptions), or a
[custom](#custom-retry-policies) one.

`options.maxTimeoutMs` The longest the connection can stay unhealthy before the
client gives up trying to reconnect.

`options.allowedCloseCodes` Optional list of codes that are considered normal
reasons for closure. When closed externally with one of these codes, the client
does not try to reconnect, and the `close` event is dispatched as usual.

#### resilient.open()

Same as [`ws.open`](#open), but if an attempt to open a connection fails, the
client tries again according to the retry policy and the `maxTimeoutMs` option.
When the client can no longer retry, it rejects with the latest error thrown by
the wrapped client.

Returns: the return value of the latest successful `open()` call on the wrapped
client. For a regular `WebSocketClient` it's `undefined`, but other behaviors
may return a value.

#### resilient.healthy()

If a reconnection attempt is currently in progress, returns a promise that
resolves when this attempt succeeds. If the attempt is unsuccessful and the
client gives up trying to reconnect, it rejects with the latest error thrown by
the wrapped client.

Resolves immediately if the connection is ready.

This promise resolves with the return value of the latest successful `open()`
call on the wrapped client. For a regular `WebSocketClient` it's `undefined`,
but other behaviors may return a value.

#### resilient.close(code, reason)

Same as [`ws.close`](#closecode-reason). If a reconnection attempt is currently
in progress, it is interrupted (as well as any other async operation). The
interrupted `resilient.healthy()` promise rejects with an `AbortedError`.

#### resilient.send(message)

Same as [`ws.send`](#sendmessage). If the connection is not ready, this method
throws an error. You might want to await the `resilient.healthy()` promise
before sending a message, although it still doesn't guarantee delivery:

```ts
await resilient.healthy();
resilient.send("ping");
```

#### resilient.exec(procedure)

Same as [`ws.exec`](#execprocedure-options). If the connection is not ready,
this method rejects. You might want to await the `resilient.healthy()` promise
before executing a procedure:

```ts
await resilient.healthy();
resilient.exec(function* ({ send, recv, expect }) {
  // ...
});
```

#### resilient.on(event, cb)

Same as [`ws.on`](#onevent-cb), but this method supports events that are
specific to the resilient behavior.

#### Events

`message` and `recverror` events are same as on the wrapped client.

`close` Dispatched when the connection is closed externally with one of the
[allowed close codes](#makeresilientws-options).

Callback signature is `(code: number, reason: string) => void`.

`gaveup` Dispatched when the connection is closed externally, and no further
reconnection attempts can be made. For example, this event is dispatched when
the connection isn't restored within [`maxTimeoutMs`](#makeresilientws-options).

Callback signature is `(error: any, code: number, reason: string) => void`,
where `error` is the latest error thrown by the wrapped client.

`reconnect` Dispatched as soon as reconnection attempts begin. At this point the
[`resilient.healthy()`](#resilienthealthy) promise represents the current
reconnection attempt.

Callback signature is `(code: number, reason: string) => void`, with the status
code and reason of the external closure that caused reconnection attempts to
begin.

### Authenticated

#### makeAuthenticated(ws, options)(handshake)

Wraps the provided client and adds a handshake procedure that runs as soon as
the connection is open. See [Authenticated behavior](#authenticated).

`ws` Client to be wrapped. It can be the `WebSocketClient` instance, or any
other `WebSocketLike` object (e.g. the client wrapped with some other behavior).

`options.timeoutMs` Maximum amount of time the handshake procedure can run
before it's considered failed.

`options.closeArgs` When connection is closed because the handshake procedure
has failed, these arguments are used as status code and reason for closure. E.g.
`[3000, "handshake failed"]`.

`handshake` Procedure that runs as soon as the connection is open. The return
value of this procedure is used to resolve the
[`authenticated.open()`](#authenticatedopen) promise. Throwing from this
procedure rejects the [`authenticated.open()`](#authenticatedopen) promise.

No `message` events are dispatched before the handshake completes.

The handshake procedure can be interrupted by timing out or closing the
connection.

All methods and events are the same as on the wrapped client, except for:

#### authenticated.open()

Same as [`ws.open`](#open), with an added handshake procedure that runs as soon
at the connection open. This method does not resolve or reject until the
handshake procedure completes, fails, or times out.

Returns: the return value of the handshake procedure.

#### authenticated.close(code, reason)

Same as [`ws.close`](#closecode-reason). If a handshake procedure is currently
in running, it is interrupted (as well as any other async operation).

### Health checked

#### makeHealthChecked(ws, options)(check)

Wraps the provided client and adds a periodically running health check. Failing
the health check closes the connection. See
[Health checked behavior](#health-checked).

`ws` Client to be wrapped. It can be the `WebSocketClient` instance, or any
other `WebSocketLike` object (e.g. the client wrapped with some other behavior).

`options.checkEveryMs` Amount of time between health checks. The countdown
starts as soon as one health check completes, so actual time between two health
checks starting can be longer than that.

`options.timeoutMs` Maximum amount of time the health check procedure can run
before it's considered failed.

`options.closeArgs` When connection is closed because the health checks
procedure has failed, these arguments are used as status code and reason for
closure. E.g. `[3008, "health check failed"]`.

`options.timers` Optional timers implementation. Since health checks are
expected to run in background and may be throttled by browsers, passing an
alternative timer implementation (e.g. running timers from a Web Worker) can
reduce throttling.

`check` Procedure that is executed periodically to perform a health check.
Throwing from this procedure closes the connection and dispatches the `close`
event.

All methods and events are the same as on the wrapped client.

### Retry

#### exponentialBackoff(options)

Retry policy that allows a specified maximum number of attempts, each with an
exponentially growing delay after an attempt.

`options.minDelayMs` Delay after the first (0) attempt. Subsequent attempts will
be delayed for longer. Default: 1 second.

`options.maxDelayMs` Maximum possible delay after an attempt. Default: no
restriction.

`options.maxAttempts` Maximum total amount of retry attempts to be made.
Default: 3.

`options.factor` Each subsequent delay is multiplied by this factor, but is
restricted by the maximum delay. Default: 2.

`options.jitter` Adds randomness to delays, in percetage of `minDelayMs`.
Default: 0.1 (±0.1 second)

With default settings, a total of 4 attempts are made by this policy:

Attempt 0: executed immediately.  
Attempt 1: after 1s ±0.1s.  
Attempt 2: after 2s ±0.1s.  
Attempt 3: after 4s ±0.1s.

#### Custom retry policies

`Retry` is an interface that can be implemented by custom retry policies. It is
an async function with the following signature:

`(context: RetryContext): Promise<boolean>`

When called, it is provided the following context:

`attempt` Current attempt index. It starts with 1, because the first (0) attempt
is always performed immediately and unconditionally.

`context.latestError` Latest error that caused a retry attempt.

Retry policy can resolve with a delay, thus implementing a delay between retry
attempts.

It must resolve with a boolean indicating if further retry attempts are allowed.
If it resolves with `false`, retries attempts stop.

### WebSocketClosedError

Async operations interrupted by abrupt external closures reject with this error.
It may contain information about the reason for closure:

`error.code` Optional status code sent when the connection closed.

`error.reason` Optional reason for closure.

### AbortedError

Async operations interrupted by closing the connection with
[`ws.close()`](#closecode-reason) reject with this error.

## Design notes

This library does its best to follow these principles:

**Don't dispatch events triggered by explicit actions in user code.**

Opening a connection with `ws.open()` doesn't dispatch `open`. Closing the
connection with `ws.close()` does not dispatch `close`.

Instead, events are triggered by _external_ actions: if the server decides to
close the connection, the `close` event is dispatched by the client.

(By the same principle input elements don't dispatch `change` when their value
is updated with `input.value = "new value"`. It's useful, since it's usually the
external that require handling.)

**Gracefully handle abrupt connection closures in async operations.**

Any operation like `ws.open()` or `ws.exec()` can be interrupted at by an abrupt
closure, caused externally (network drop, server went away, etc.) or by an
explicit `ws.close()` call.

In case of an abrupt external closure, async operations reject with a
`WebSocketClosedError`.

When interrupted by an explicit `ws.close()` call, async operations reject with
an `AbortedError`.

This is particularly useful when using WebSocket from UI components. Since UI
can be unmounted and cleaned up at any moment, it should be a requirement that
`ws.close()` is always safe to call.
