@stream-io/ws
v0.4.4
Published
WebSocket client for browsers and Node.js
Maintainers
Keywords
Readme
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.
Installation
npm install @stream-io/ws
yarn add @stream-io/ws
pnpm add @stream-io/wsBasics
Opening a connection
Create a client instance and open a connection:
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():
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:
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:
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.
To receive a message, listen for the message event:
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:
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():
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:
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():
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 />:
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:
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:
- connection closure can be initiated server-side;
- server can go offline abruptly;
- 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:
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):
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:
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:
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
that are executed with ws.exec().
Make sure you've awaited ws.open() before executing a procedure.
Supported commands are:
send(message)instructs the client to send a message;recv()instructs the client to wait for an incoming message, and returns the message payload;expect(predicate)checks the next incoming message against the predicate and throwsUnexpectedMessageErrorif the check fails.settle(promise)awaits a promise. This comes with some caveats, see below.
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:
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":
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:
- Any message that arrives while waiting is missed by subsequent
recvorexpectcomamnds. If the procedure is running withsuppressMessageEventsoption enabled, it's missed completely. - If the connection closes while waiting, the procedure is interrupted immediately, but the async action itself will keep executing.
- 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:
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:
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:
- handle these errors, or
- await the
resilient.healthy()promise before sending a message or executing a procedure:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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().
options.url URL of the WebSocket endpoint. Usually starts with ws:// or
wss://.
options.format Message format to parse (deserialize) and format
(serialize) message payloads. Use textFormat() to send a
receive raw UTF-8 strings, and jsonFormat<T>() 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 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(), in which case this method rejects with an
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() 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 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. Make sure you await
ws.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 which contains a status
code and reason for closure.
Executing the procedure can be interrupted by calling
ws.close(), in which case this method rejects with an
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.
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.
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:
- the parser for the provided message format returned
recvIgnore(), a special value indicating that the message must be ignored; - a procedure is running with the
suppressMessageEventsoption.
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() does not dispatch this event.
Formats
textFormat()
Pass this format to the WebSocketClient constructor to
send and receive messages as UTF-8 strings.
jsonFormat()
Pass this format to the WebSocketClient constructor 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:
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.
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()
value.
Resilient
makeResilient(ws, options)
Wraps the provided client and makes it resilient to abrupt closures. See Resilient behavior.
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, or a
custom 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, 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. 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. 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:
await resilient.healthy();
resilient.send("ping");resilient.exec(procedure)
Same as ws.exec. If the connection is not ready,
this method rejects. You might want to await the resilient.healthy() promise
before executing a procedure:
await resilient.healthy();
resilient.exec(function* ({ send, recv, expect }) {
// ...
});resilient.on(event, cb)
Same as ws.on, 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.
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.
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() 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.
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() promise. Throwing from this
procedure rejects the authenticated.open() 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, 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. 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.
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() 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.
