4 min read
Library Design

Let’s write a WebSocket client for modern browsers without dependencies that prioritizes an excellent developer experience. I want to explore the native WebSocket API while creating a library that’s easy to use, extend, and tailor to different projects. You can follow along with the code on GitHub at DurableWS v0.0.1.

Design Patterns in Action

Here’s the fun part. I’m focusing on proven patterns that are as much about clarity as they are about power:

  1. Functional Composition: To build the client using small, reusable functions for maximum flexibility and clarity.
  2. Pub/Sub Pattern: An event bus for handling events cleanly and decoupling logic.
  3. Store: Built upon the event bus to effectively manage WebSocket states and actions.
  4. Middleware: To intercept actions and add functionality without touching core code. This will be great for adding features to the library.

Why Another WebSocket Library?

The real reason is that Socket.IO is one of the only clients with a great developer experience and complete features. However, it requires a custom schema, and not all servers are compatible with Socket.IO. I want to create something more flexible and adaptable to different situations. DurableWS is designed to be easy to extend and tailor to any use case. It should be simple to add compatibility with Socket.IO’s API and any other library or standard you might need to meet. By leveraging composition and middleware, we can quickly construct a WebSocket client that meets specific requirements without a ton of boilerplate or reinventing the wheel.

Tools and Frameworks

Nothing fancy, just what works:

  • TypeScript: Because TypeScript improves code quality significantly, and others have already explained its benefits better than I ever could.
  • Native WebSocket API: Leveraging the browser’s built-in capabilities without additional polyfills or wrappers.
  • Vitest: Fast, modern testing to catch issues early.

Project Structure

To keep things maintainable, I’m organizing the code like this:

src/
  ├─ index.ts           // Exposes defineClient() as the main entry point
  ├─ client.ts          // Core logic for building the WebSocket client
  ├─ middleware/        // Directory for middleware functions
  ├─ actions/           // Directory for action handlers
  │   ├─ connection-handlers.ts // Handlers for connection-related actions
  │   ├─ message-handlers.ts    // Handlers for message-related actions
  ├─ helpers/           // Directory for helper functions
  │   ├─ event-bus.ts   // Module to manage event listeners, pub/sub
  │   ├─ store.ts       // Store logic for managing state and actions
  ├─ types.ts           // All type definitions, enums, interfaces
  ├─ utils.ts           // Utility functions

This structure emphasizes the separation of concerns, making it easier to navigate and extend.

CI/CD Setup

Here’s a basic GitHub Actions workflow to ensure the code stays solid. I created a yaml file at .github/workflows/ci.yml with the following content:

name: Node.js CI

on: [push]

jobs:
    build:
        runs-on: ubuntu-latest

        steps:
            - uses: actions/checkout@v4
            - name: Use Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: "20.x"
            - run: npm ci
            - run: npm run lint
            - run: npm test

TypeScript

I like to add a path alias to the project root in the tsconfig.json file. This allows me to import files from the root of the project without having to specify the full path using @/.

{
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"]
        }
    }
}

This is just the start. Over the next few posts, I’ll dive deeper into DurableWS’s architecture and features. Whether you’re building a real-time dashboard or want to learn new patterns, stick around—there’s plenty to explore.