Issue#14 - Implementing WebSocket Client See on GitHub repository
Real time communication protocols referes to continous exchange of data with minimal latency. Unlike traditional request-response model where data exchange happens on demand, live updates happens w/o requirement of refreshing UI.
The WebSocket Protocol is a real-time communication protocol that provides bi-directional data exchange, mostly this behavior called full-dublex communication, over persistent single TCP connection between client and the server.
To communicate websocket servers, client parties requires to be instrumented related implementations. Although there are third party solutions provides solid implementations for websocket client instrumentation, browsers native WebSocket Interface introduces robust API for creating and managing connection to the server, as well as for sending and receiving data on the connection.
The WebSocket constructor takes one mandatory argument, the URL of the websocket server to connect to.
const uri = "ws://localhost:80/ws;
const websocket = new WebSocket(uri);Important
Similar to HTTP and HTTPs, WebSockets have a unique set of prefixes: ws and wss for the connections without and withTLS respectively. This issue will cover ws connections since its connecting localhost only. Production level applications should be served using wss as the protocol.
The constructor will throw a SecurityError if the destination does not allow access. This also may happend due to attemps on insecure connections due most user agents now require a secure link for all websocket connections unless either party on the same device or on the same network.
The WebSocket constructor takes another optional argument protocols, a single string or an array of strings, to implement multiple sub-protocols.
WebSocket Interface API has following events to be able to listen, close, error, message and open. These events can be listened using native javascript's addEventListener() or by assigning an event listener to the oneventname property of this interface.
For an example, once the connection is established, the open event is fired. Following code example, sending one ping message to the server every second after connection is opened.
websocket.addEventListener("open", () => {
log("CONNECTED");
pingInterval = setInterval(() => {
log(`SENT: ping: ${counter}`);
websocket.send("ping");
}, 1000);
});-
closeFired when a connection with a WebSocket is closed. Also available via the
oncloseproperty -
errorFired when a connection with a WebSocket has been closed because of an error, such as when some data couldn't be sent. Also available via the
onerrorproperty. -
messageFired when data is received through a WebSocket. Also available via the
onmessageproperty. -
openFired when a connection with a WebSocket is opened. Also available via the
onopenproperty.
An WebSocket instance can only send message(s) once the connection is established and is alive.
The websocket.send(data) method enqueues the specified data to be transmitted to server. It increasing the value of bufferedAmount by the number of bytes needed to contain the data.
Whether the data can not be sent, buffer might be full or any other error may occur, the socket closed automatically. The browser will throw an exception if send() is called during CONNECTING state. On the other hand, the browser will silently discards the data on CLOSING or CLOSED states of connection.
The send() method is asynchronous. It does not wait for the data to be transmitted before returning to the caller. It just adds the data to its internal buffer and begins the process of transmission. The WebSocket.bufferedAmount property represents the number of bytes that have not yeet been transmitted.
Important
If protocol uses UTF-8 to encode text, so bufferedAmount is calculated based on the UTF-8 encoding of any buffered text data.
Nevertheless UTF-8 text type of data is mostly used, data may sent over as Blob, ArrayBuffer, TypedArray or DataView. See MDN Documentation over WebSocket.Send for more details.
A common approach to use JSON to send serialized objects as text. For example, instead of just sending the text message "ping", our client could send a serialized object including the number of messages exchanged so far:
const message = {
iteration: counter,
content: "ping",
};
websocket.send(JSON.stringify(message));In order to handle message receiving part, application can listen for the message event.
The server can also send binary data, which is exposed to clients as a Blob or an ArrayBuffer based on the value of the WebSocket.binaryType property.
websocket.addEventListener("message", (e) => {
const message = JSON.parse(e.data);
log(`RECEIVED: ${message.iteration}: ${message.content}`);
counter++;
});The WebSocket.binaryType instance property controls the type of binary data being received over the websocket connection. It is a string type of property that can be set blob which is the default or arraybuffer.
// Create WebSocket connection.
const socket = new WebSocket("ws://localhost:8080");
// Change binary type from "blob" to "arraybuffer"
socket.binaryType = "arraybuffer";
// Listen for messages
socket.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
// binary frame
const view = new DataView(event.data);
console.log(view.getInt32(0));
} else {
// text frame
console.log(event.data);
}
});Issue 14 subjects to implement websocket client to the frontend application. Scope and decisions requireds to develop a custom hook that able to connect given websocket endpoint, and can handle connection lifecyle and real-time data transmission events. In addition to that, no other third party integrations are restricted despite using browser's native WebSocket Interface. A basic connection status representer UI component is also required to test out the connection more straightforward.
In order to provide re-usable functionalities and easy implementations with a custom hook, useWebSocket hook handles socket events implicitly open, error, close, message and exposes send and close functionalities. However, hook signature also accepts optional event handlers to be run on relevant connection or transmission event.
export default function useWebSocket(g: {
url: string;
messageListeners: onMessage[];
onOpen?: onEvent;
onDisconnect?: onEvent;
onError?: onEvent;
});
// where
type onMessage = {
type: string;
handler: (event: MessageEvent) => void;
};
type onEvent = (event: Event) => void;Initializing State Variables
export default function useWebSocket(g: {
url: string;
messageListeners: onMessage[];
onOpen?: onEvent;
onDisconnect?: onEvent;
onError?: onEvent;
}) {
const [connectionState, setConnectionState] = useState<WsConnectionStatus>(
WsConnectionStatus.IDLE,
);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
const listeners = useRef<onMessage[]>(g.messageListeners);As a first step of the implementation, internal state parameters and references are defined to manage the lifecycle and behavior of the connection:
connectionState: A state variable that represents the current connection status. It is initialized withWsConnectionStatus.IDLEwhich indicates no connection attempts or results yet.errorMsg: Another straightforward state variable that represents the possible error encountered during connection lifecyle. It is initialized as null and updated when an error occurs, allowing the consumer of the hook to react accordingly (e.g., displaying error feedback in the UI).webSocketRef: A mutable reference to an activeWebSocketinstance. This reference aiming to prevent additional render triggerations and ensuring using sameWebSocketinstance through the same component's lifecyle.listeners: A mutable reference containing the array of message listener callbacks (onMessage[]) provided via the hook's input. By storing listeners in a useRef, the hook avoids unnecessary re-subscriptions or re-initializations when the component re-renders, while still allowing access to the latest listener set.
Establishing Connection
Websocket connections always initiated from the client party via initial handshake request. As mentioned, useWebSocket hook implicitly handles connection lifecycles while accepting listener methods in its signature.
On the other hand, creating a WebSocket instance starts the process of establishing a connection to the server. Thus in the private connect function:
export default function useWebSocket(g: {
url: string;
messageListeners: onMessage[];
onOpen?: onEvent;
onDisconnect?: onEvent;
onError?: onEvent;
}) {
//.. state vaariable part
const connect = useCallback(() => {
// clears the previous reference whether there is
if (webSocketRef) webSocketRef.current = null;
try {
const ws = new WebSocket(g.url);
setConnectionState(WsConnectionStatus.CONNECTING);
webSocketRef.current = ws;
//..codeConnection initiating part was wrapped with a trycatch block to handle constructer errors. After connection is initiated, connection status is set to CONNECTING until the onopen event is triggered.
try {
const ws = new WebSocket(g.url);
setConnectionState(WsConnectionStatus.CONNECTING);
webSocketRef.current = ws;
//handling onopen event
webSocketRef.current.onopen = (event) => {
setConnectionState(WsConnectionStatus.OPEN);
if (g.onOpen) g.onOpen(event);
};
//code..onopen handler alters the connection status, and optionally calls the defined onOpen callback parameter.
Similarly, connect methods declares other event handlers implicitly,
webSocketRef.current.onmessage = (event) => {
try {
listeners.current.forEach((listener) => listener.handler(event));
} catch (e) {
setErrorMsg(`${e}`);
}
};
webSocketRef.current.onclose = (event) => {
setConnectionState(WsConnectionStatus.CLOSED);
if (g.onDisconnect) g.onDisconnect(event);
};
webSocketRef.current.onerror = (event) => {
setConnectionState(WsConnectionStatus.ERROR);
if (g.onError) g.onError(event);
};close and send handlers
Hook also defines and exposes data sending and connection closing functionalities.
Since application scope is not requiring media transfer, high frequency data traffic or custom binary protocol implementations, using JSON Stringified text data for transmisson is sufficient. It will provide straightforward debugging and neglected complexity for data serialization and deserialization.
On the other hand, WebSocket interface api implicitly masking the payload. Thus any masking logic was used in the sendMessage function.
const sendMessage = useCallback(
(data: { type: string; payload: unknown }) => {
try {
if (
webSocketRef.current &&
webSocketRef.current.readyState != webSocketRef.current.OPEN
) {
throw new Error(
`connection is not open: ${webSocketRef.current.readyState}`,
);
}
const serialized = JSON.stringify(data);
webSocketRef.current?.send(serialized);
} catch (error) {
setErrorMsg("serialization error");
console.log(`serialization error ${error}`);
}
},
[webSocketRef],
);Exposed closeConnection is straightforward.
const closeConnection = useCallback(() => {
webSocketRef.current?.close();
}, [webSocketRef]);Control Frames
Modern browsers preventing creating and sending ping-pong frames while automatically providing a keep-alive functionality themselves.
Configuring hook lifecyle
Using React's useEffect hook, our custom hook's setup and clean-up logic is configured.
useEffect(() => {
connect();
return () => {
if (webSocketRef?.current?.readyState != WebSocket.CLOSED) {
webSocketRef?.current?.close();
}
};
}, [g.url]);Given configuration ensures, each time component commits React will automatically initites ws connection. In contrast, when the component is removed from the DOM React will automatically close the connection.