4 min read
The Client Interface

The WebSocket client is the entry point for DurableWS, and it defines the public API for the entire library. That’s why it’s so important to get it right. Having a clear and consistent client API is the foundation of a great developer experience.

I like the word “define” in the function name (defineClient) to signal intention. It’s a personal preference, but it also reflects a philosophy: be explicit and purposeful when setting up key components like the client, and let everything else build around that clarity.

Setup

As a quick reminder, here are the files that I’m going to be referencing in this article:

src/
  ├─ index.ts
  ├─ client.ts
  ├─ types.ts

You’re the right Type

Lets create foundational type definitions that power the library. The types.ts file defines the interfaces for the WebSocket client configuration and the client itself. For now the config just takes a url.

export interface WebSocketClientConfig {
    url: string;
    // TODO: Add other config options here
}

export interface WebSocketClient {
    // TODO: Add other methods here
}

Entry Point

The index.ts file is the entry point for the library. It exposes the defineClient function, which is the main way to configure and instantiate the WebSocket client. I decided to create a singleton pattern here, exposing a single entry point for the entire library and preventing multiple instances from being created at once.

import { client } from "@/client";
import type { WebSocketClient, WebSocketClientConfig } from "@/types";

// Singleton instance of the WebSocketClient
let instance: WebSocketClient | null = null;
export function defineClient(config: WebSocketClientConfig) {
    if (instance) return instance;
    instance = client(config);
    return instance;
}

The Core

The client.ts file contains the core logic for the WebSocket client. This is where we interact with the native WebSocket API—a standardized interface for full-duplex communication channels over a single TCP connection. You can learn more about it on MDN’s WebSocket documentation and they also have some examples of how to use it.

import type { WebSocketClientConfig, WebSocketClient } from "@/types";

export function client(config: WebSocketClientConfig): WebSocketClient {
    let ws: WebSocket | null = null;

    function connect() {
        if (ws) {
            // If not closed, no need to reconnect
            if (ws.readyState !== WebSocket.CLOSED) {
                return;
            }
        }

        ws = new WebSocket(config.url);
    }

    function close() {
        ws?.close();
        ws = null;
    }

    return {
        connect,
        close,
    };
}

The Native WebSocket

At its core, the WebSocket object handles the heavy lifting of establishing and maintaining the connection. It has several ready states, which are represented by constants on the WebSocket object:

  • CONNECTING (0): The connection is being established.
  • OPEN (1): The connection is open and ready to communicate.
  • CLOSING (2): The connection is in the process of closing.
  • CLOSED (3): The connection is closed or could not be opened.

In our connect method, we first check if the ws instance already exists and whether it is in the CLOSED state. This ensures we don’t accidentally attempt to reconnect an active or closing WebSocket connection.

Exposing the Methods

We start small by exposing just two methods: connect and close.

  1. Connect Method:

    • This method initializes a new WebSocket instance using the provided url from the configuration.
    • If a WebSocket instance already exists and is not closed, the function exits early, avoiding redundant connections.
  2. Close Method:

    • This safely closes the WebSocket connection using the close method from the native API.
    • After closing, it nullifies the ws reference to free up resources and ensure that subsequent calls to connect can establish a fresh connection.

This design keeps the client simple and focused. It’s a starting point that can easily be expanded with additional methods, like sending or handling messages, in the future. By separating this core logic from the library’s entry point, we also make it easier to unit test this functionality in isolation.


In the next article I’ll dive into creating an Event Bus that will allow us to manage events and actions in a more organized way.