Skip to content

Durable Object WebSocket Service

This document outlines the setup, configuration, architecture, and usage of the real-time WebSocket service powered by Cloudflare Workers and Durable Objects.


1. How to Configure

Initial Project Setup

  • Clone the Repository: git pull <repository-url>
  • Install Dependencies: This project uses pnpm for package management, use pnpm install on root project

Wrangler CLI

wrangler is the official command-line tool for managing Cloudflare Worker projects. It's the primary interface for development, deployment, and configuration. Since it's inside devDependencies, it should be installed in your project with pnpm install. If not, you can install it globally with:

npm install -g @cloudflare/wrangler

Main Commands:

  • wrangler dev: Starts a local development server that emulates the Cloudflare environment, allowing for live-reloading and testing
  • wrangler deploy: Deploys the current state of your project to the Cloudflare global network, making it live
  • wrangler types: Generates TypeScript type definitions for your environment variables and bindings, enabling full type-safety
  • wrangler secret put <SECRET_NAME>: Securely uploads a secret (like an API key) to your Worker's environment. It will prompt you to paste the secret value

wrangler.jsonc Configuration

This file is the heart of the project's configuration. It tells Wrangler how to build, bundle, and deploy the service.

"$schema": "node_modules/wrangler/config-schema.json",
"name": "office-router",
"main": "src/worker/index.ts",
"compatibility_date": "2025-09-24",
"migrations": [
    {
        "new_sqlite_classes": [
            "WebSocketServer"
        ],
        "tag": "v1",
    }
],
"durable_objects": {
    "bindings": [
        {
            "name": "WEBSOCKET_SERVER",
            "class_name": "WebSocketServer"
        }
    ],
},

Configuration Parameters:

  • name: The name of your service in the Cloudflare dashboard
  • main: The entry point file for the Worker. All requests are handled by this file first
  • compatibility_date: Locks the Worker to a specific version of the Cloudflare runtime API to prevent breaking changes
  • durable_objects: This section is critical
  • bindings: Defines how your code will access the Durable Object. In this case, it creates a binding named WEBSOCKET_SERVER that is linked to the WebSocketServer class exported from your code
  • migrations: Used to manage changes to your Durable Objects, such as renaming a class, without losing stored data

Local Development Environment

Local development relies on a .dev.vars file placed at the root of the project (/qubital-do-worker/.dev.vars). This file is not checked into git and contains the secrets needed to run the service locally.

Example .dev.vars:

CF_ACCESS_CLIENT_ID="your-local-client-id"
CF_ACCESS_CLIENT_SECRET="your-local-client-secret"
CONN_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"

To ensure TypeScript recognizes these environment variables, they are declared in worker-configuration.d.ts. While wrangler types is supposed to manage this, you may sometimes need to add declarations manually to provide types for your secrets.

Example worker-configuration.d.ts:

// worker-configuration.d.ts 
declare namespace Cloudflare {
    interface GlobalProps {
        mainModule: typeof import("./src/index");
        durableNamespaces: "WebSocketServer";
    }
    interface Env {
        CF_ACCESS_CLIENT_ID: string;
        CF_ACCESS_CLIENT_SECRET: string;
        CONN_JWT_PUBLIC_KEY: string;
        // ... other secrets
        WEBSOCKET_SERVER: DurableObjectNamespace<import("./src/index").WebSocketServer>;
    }
}
interface Env extends Cloudflare.Env {}

2. General Concepts

The service is designed as a multi-tenant system where each customer organization has its own isolated real-time environment.

Organization-Scoped Architecture

  • Each customer organization is mapped to a single, unique Durable Object (DO) instance
  • This provides strong data isolation, as one organization's connections and messages are never processed in the same memory space as another's
  • The Worker routes incoming requests to the correct DO instance by generating a unique ID from the user's orgId, which is extracted from their authentication token (idFromName(session.orgId))

Authentication Flow

There are two distinct entry points into the system, each with its own authentication method.

Client WebSocket Connection (GET /websocket)

  • A user's client initiates a connection by making an HTTP GET request with an Upgrade: websocket header
  • Authentication is performed using a short-lived JSON Web Token (JWT)
  • Why the header? The JWT is passed inside the Sec-WebSocket-Protocol header. This is a standard and secure way to pass authentication tokens during the WebSocket upgrade handshake
  • The Worker (authentication helper in auth.ts) validates this JWT. If successful, it forwards the request to the DO, passing the validated user session data (userId, orgId, role) securely in an internal X-Session-Data header

Privileged Backend Notification (POST /internal/notify)

  • This endpoint is used exclusively by our Go backend to push authoritative state changes to the clients (e.g., a user's role has been updated)
  • Authentication is performed using a Cloudflare Access Service Token. The backend must provide CF-Access-Client-Id and CF-Access-Client-Secret headers
  • This is a highly secure machine-to-machine authentication method that ensures only our trusted backend can trigger these system-wide notifications

3. How It Works: Messages & Core Logic

Message Flow and Contracts

The entire system is built around a strongly-typed message contract defined in src/types.ts using the Zod validation library. This ensures that any malformed data from either the client or the backend is rejected at the boundary.

  • Backend → DO → Clients: The Go backend sends a full message object via POST /internal/notify. The Worker validates the orgId for routing and forwards the request to the DO. The DO performs full validation, executes any necessary side-effects, and then routes the message to clients as either a broadcast (to all) or direct (to specific targets)
  • Client → DO → Clients: A connected client sends a message (like POSITION_UPDATE) over its WebSocket. The DO validates the message and checks if the message type is allowed to be sent by clients (defined in ALLOWED_CLIENT_MESSAGE_TYPES). It then forwards the message to other clients

Side-Effects

To keep the core Durable Object logic clean (managing connections, routing), any action that mutates the DO's in-memory state is handled by a "side-effect".

  • The actions/index.ts file contains a registry that maps a message type (e.g., USER_UPDATED) to a specific handler function (handleUserUpdate)
  • When the DO receives a notification from the backend, it checks this registry. If a handler exists, it executes it, passing the DO's state (the sessions and users maps) and the message
  • This pattern makes the business logic modular and easy to test or modify. For example, handleUserUpdate finds the specific user's session in the sessions map and updates their role

4. Useful Scripts

The package.json file contains several scripts to streamline development:

  • pnpm dev: Starts the local development server with live-reloading
  • pnpm deploy: Deploys the service to Cloudflare
  • pnpm cf-typegen: Runs wrangler types to generate TypeScript definitions
  • pnpm token:generate: A vital script for local testing. It generates a valid JWT for connecting to the WebSocket endpoint

How to Use token:generate

The script accepts command-line arguments to customize the token payload:

# Format: pnpm token:generate [userId] [orgId] [role]
# Example for a standard member:
pnpm token:generate test-user-1 org-123 member

# Example for a guest who is about to knock:
pnpm token:generate guest-user-1 org-123 pending_guest

This will print a valid JWT to the console, which can be used in WebSocket clients like wscat.


5. Testing with cURL

Here are example curl commands for testing the /internal/notify endpoint directly. Remember to replace placeholder values.

Test 1: Room Updated (Broadcast)

Purpose: Tests a broadcast message that should be received by all clients in the organization.

Expected Outcome: 200 OK

curl -X POST "http://localhost:8787/internal/notify" \
-H "Content-Type: application/json" \
-H "CF-Access-Client-Id: your-client-id" \
-H "CF-Access-Client-Secret: your-client-secret" \
-d '{
    "orgId": "org_01K2Z1R2MEJK6B41WW7428G9YC",
    "routing": "broadcast",
    "type": "room_updated",
    "payload": {
        "roomId": "room-main-hall-123",
        "name": "Main Hall"
    }
}'

Test 2: Space Deleted (Broadcast)

Purpose: Tests a system-wide broadcast event.

Expected Outcome: 200 OK

curl -X POST "http://localhost:8787/internal/notify" \
-H "Content-Type: application/json" \
-H "CF-Access-Client-Id: your-client-id" \
-H "CF-Access-Client-Secret: your-client-secret" \
-d '{
    "orgId": "org_01K2Z1R2MEJK6B41WW7428G9YC",
    "routing": "broadcast",
    "type": "space_deleted",
    "payload": {
        "spaceId": "space-pepito-main-456",
        "name": "Pepito HQ"
    }
}'

Test 3: User Banned (Direct)

Purpose: Tests a direct message that should only be received by the targeted user.

Expected Outcome: 200 OK

curl -X POST "http://localhost:8787/internal/notify" \
-H "Content-Type: application/json" \
-H "CF-Access-Client-Id: your-client-id" \
-H "CF-Access-Client-Secret: your-client-secret" \
-d '{
    "orgId": "org_01K2Z1R2MEJK6B41WW7428G9YC",
    "routing": "direct",
    "type": "user_banned",
    "targets": ["user_01JZAZ78ZD9GZQXH3YKQ5WFXVP"],
    "payload": {
        "userId": "user_01JZAZ78ZD9GZQXH3YKQ5WFXVP",
        "reason": "Blablabla"
    }
}'

Additional Notes

Guest Knock Flow

  • The GUEST_KNOCK message type is currently defined in the system. As of this writing, the contract expects the backend to provide a list of targets to notify
  • The exact flow of how a guest initiates this is still under discussion. The recommended secure approach is for the guest to make an HTTP call to the backend, which then determines the correct targets and initiates the GUEST_KNOCK notification itself, preventing the guest client from having any control over who gets notified
  • Another possible flow is to automatically send that message on correct routing (which is after he requested to join). We can check if user's role who belongs to that ws connection is pending_guest, then send a knock message, since this flow is after he, theoretically, already knocked