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 installon 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 testingwrangler deploy: Deploys the current state of your project to the Cloudflare global network, making it livewrangler types: Generates TypeScript type definitions for your environment variables and bindings, enabling full type-safetywrangler 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 dashboardmain: The entry point file for the Worker. All requests are handled by this file firstcompatibility_date: Locks the Worker to a specific version of the Cloudflare runtime API to prevent breaking changesdurable_objects: This section is criticalbindings: Defines how your code will access the Durable Object. In this case, it creates a binding namedWEBSOCKET_SERVERthat is linked to theWebSocketServerclass exported from your codemigrations: 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: websocketheader - Authentication is performed using a short-lived JSON Web Token (JWT)
- Why the header? The JWT is passed inside the
Sec-WebSocket-Protocolheader. 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 internalX-Session-Dataheader
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-IdandCF-Access-Client-Secretheaders - 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 theorgIdfor 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 inALLOWED_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.tsfile 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
sessionsandusersmaps) and the message - This pattern makes the business logic modular and easy to test or modify. For example,
handleUserUpdatefinds the specific user's session in thesessionsmap 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-reloadingpnpm deploy: Deploys the service to Cloudflarepnpm cf-typegen: Runswrangler typesto generate TypeScript definitionspnpm 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_KNOCKmessage 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_KNOCKnotification 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