ricea / websocketstream-explainer

Explainer for the WebSocketStream JavaScript API
Apache License 2.0
126 stars 5 forks source link
javascript streams web-standards websocket

WebSocketStream Explained

Introduction

The WebSocket API provides a JavaScript interface to the RFC6455 WebSocket protocol. While it has served well, it is awkward from an ergonomics perspective and is missing the important feature of backpressure. In particular,

WebSocketStream aims to solve these deficiencies with a new API.

Here’s a basic example of usage of the new API:

const wss = new WebSocketStream(url);
const { readable } = await wss.opened;
const reader = readable.getReader();
while (true) {
  const { value, done } = await reader.read();
  if (done)
    break;
  await process(value);
}
done();

This is the roughly equivalent code with the old API:

const ws = new WebSocket(url);
ws.onmessage = evt => process(evt.data);
ws.onclose = evt => evt.wasClean ? done() : signalErrorSomehow();

The major difference is that the second example won’t wait for asynchronous activity in process() to complete before calling it again; it will keep hammering it as long as messages keep arriving.

Also note that because the old API was designed before Promises were added to the language, error-handling is awkward.

Writing also uses the backpressure facilities of the Streams API:

const wss = new WebSocketStream(url);
const { writable } = await wss.opened;
const writer = writable.getWriter();
for await (const message of messages) {
  await writer.write(message);
}

The second argument to WebSocketStream is an option bag to allow for future extension. One option is “protocols”, which behaves the same as the second argument to the WebSocket constructor:

const wss = new WebSocketStream(url, {protocols: ['chat', 'chatv2']});
const { protocol } = await wss.opened;

The selected protocol is part of the dictionary available via the wss.opened promise, along with “extensions”. All the information about the live connection is provided by this promise, since it is not relevant if the connection failed.

const { readable, writable, protocol, extensions } = await wss.opened;

The information that was available from the onclose and onerror events in the old API is now available via the “closed” Promise. This rejects in the event of an unclean close, otherwise it resolves to the code and reason sent by the server.

const { closeCode, reason } = await wss.closed;

An AbortSignal passed to the constructor makes it simple to abort the handshake.

const wss = new WebSocketStream(url, { signal: AbortSignal.timeout(1000) });

The close method can also be used to abort the handshake, but its main purpose is to permit specifying the code and reason which is sent to the server.

wss.close({closeCode: 4000, reason: 'Game over'});

Mapping to the protocol

There is a 1:1 mapping between WebSocket messages and stream chunks.

Each call to read() returns one WebSocket message. If a message is split into multiple frames on the wire, it won't be returned by read() until the final frame (the one with the FIN flag set) arrives.

When read() is not called, the browser and operating system will still buffer data to some extent, so backpressure will not be detected immediately by the server.

Text messages appear in JavaScript as strings. Binary messages appear as Uint8Array objects.

A clean close will result in read() returning an object with done set to true. An unclean close will result in a rejected promise.

Each call to write() (or chunk that is piped into the writable) will be converted to one message. The browser may split the message into multiple frames. BufferSource (ArrayBuffer or TypedArray) objects will be sent as binary WebSocket messages. Any other type will be converted to a string and sent as a text message.

The promise returned by write() will resolve when the message has been buffered (either by the browser or operating system). The size of the buffer is finite but unspecified. It is not a signal that the message has been delivered to the WebSocket server (the browser does not have this information).

The promise returned by write() will reject if the connection is closed or errored.

Goals

Non-goals

Non-goals in the first version

Use cases

End-user benefits

Applications written with the new API will automatically be more responsive due to respecting backpressure. High throughput applications will adapt to the capabilities of the client, providing everyone with a smooth experience.

Alternatives

It’s possible to implement backpressure at the application level, but it’s complex and difficult to achieve peak throughput. For example, client JavaScript could send an application-level confirmation message to the server every time it finishes processing a message. The server could keep track of how many messages it has sent that have not yet been confirmed, and stop sending if the number gets above a certain threshhold.

Aside from backpressure, the rest of the API can be emulated by wrapping the existing WebSocket API, but this will not permit future extensions.

Adding new attributes to the existing WebSocket API was considered but not adopted because having two APIs on one object would be confusing and create odd semantics.

WebTransport also provides backpressure, and may replace WebSocket for many purposes. In the near future the WebSocket protocol has the advantage that it works on networks that block QUIC, and has much existing deployed infrastructure.

An older version of this explainer had the readable stream producing ArrayBuffer chunks. This was changed to Uint8Array chunks to align better with WebTransport and modern practice.

Previously the closeCode attribute was called code, but this conflicted with the code attribute of DOMException.

Future work

See also