Relay & Durable Objects

Using Durable Objects as a WebSocket Session State Machine

TermOnMac’s relay server uses a Cloudflare Durable Object (the Room class) to manage the WebSocket connections between a Mac and an iPhone. Because a Durable Object routes all requests for a given ID to the same instance, in-memory socket references stay valid for the lifetime of the connection.

The Room DO Structure

// relay_server/src/room.ts
export class Room implements DurableObject {
  private macSocket: WebSocket | null = null;
  private iosSocket: WebSocket | null = null;

  // Usage metering: in-memory counters flushed to KV periodically
  private pendingMessages: Map<string, number> = new Map(); // userId → count
  private userIdBySocket: Map<WebSocket, string> = new Map();
  private lastUsageFlush = 0;
  private sessionStartTime = 0;

Two socket slots: one for the Mac, one for the iPhone. The rest is usage accounting.

WebSocket Upgrade

Incoming WebSocket connections are upgraded using WebSocketPair. Each accepted socket gets a UUID tag for hibernation recovery:

async fetch(request: Request): Promise<Response> {
  // ...
  const pair = new WebSocketPair();
  const [client, server] = Object.values(pair);

  // Accept with a unique tag for hibernation recovery
  const socketId = crypto.randomUUID();
  this.state.acceptWebSocket(server, [socketId]);

  // Store role and user ID in DO storage keyed by socketId
  await this.state.storage.put(`clientRole:${socketId}`, clientRole);
  await this.state.storage.put(`user:${socketId}`, userId);

  return new Response(null, { status: 101, webSocket: client });
}

client is returned to the connecting device. server stays in the DO and is referenced by macSocket or iosSocket depending on the client’s declared role.

Quota Check at Connection Time

Right after the WebSocket is accepted — but before any application-level messages are processed — the server checks quota. If exceeded, it sends a structured message on the open socket and closes it:

const { allowed, usage, limit, extraQuota } = await checkQuota(
  this.env.USAGE_KV, userId, tierLimits.credits_per_window
);
if (!allowed) {
  const msg: QuotaExceededMessage = {
    type: "quota_exceeded",
    usage: usage.total_tokens,
    limit,
    period: usage.period,
    resets_at: nextResetTime(usage.period),
  };
  server.send(JSON.stringify(msg));
  server.close(1000, "quota exceeded");
  return new Response(null, { status: 101, webSocket: client });
}

The response is still 101 Switching Protocols — quota failure is communicated as a WebSocket message, not an HTTP error, because a body cannot accompany a 101 response.

Message Routing

The core relay logic: forward encrypted payloads between the two sockets without inspecting them.

case "relay":
  const target = ws === this.macSocket ? this.iosSocket : this.macSocket;
  if (target) {
    target.send(JSON.stringify({ type: "relay", payload: msg.payload }));
    const userId = this.userIdBySocket.get(ws);
    if (userId) {
      this.pendingMessages.set(userId,
        (this.pendingMessages.get(userId) ?? 0) + 1);
    }
  }
  break;

pendingMessages accumulates message counts in memory. They are flushed to Cloudflare KV every 60 seconds via a DO alarm — not on every message.

Timing Constants

// relay_server/src/room.ts
const HEARTBEAT_INTERVAL = 30_000;      // Mac sends heartbeat every 30s
const HEARTBEAT_DEAD_THRESHOLD = 45_000; // 45s without any message → dead (~1.5 missed heartbeats)
const ROOM_IDLE_TIMEOUT = 600_000;      // 10 minutes idle → room cleaned up
const USAGE_FLUSH_INTERVAL = 60_000;    // flush usage counters to KV every 60s
const ROOM_REG_TTL = 900;               // 15 minutes room registration TTL in KV

The 45-second dead threshold (1.5× the 30-second interval) means a single missed heartbeat does not trigger a disconnect.

lastSeen Tracking

Every message from either client updates a lastSeen timestamp in DO storage:

async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void> {
  if (ws === this.macSocket) await this.state.storage.put("lastSeen:mac", Date.now());
  else if (ws === this.iosSocket) await this.state.storage.put("lastSeen:ios", Date.now());
  // ...
}

The /status endpoint exposes these for external monitoring:

return Response.json({
  exists: true,
  mac_connected: this.macSocket !== null,
  ios_connected: this.iosSocket !== null,
  mac_last_seen: macLastSeen || null,
  ios_last_seen: iosLastSeen || null,
  idle_seconds: Math.floor((now - lastActivity) / 1000),
});

Hibernation Recovery

When a DO hibernates (no active requests), in-memory socket references are lost. The DO constructor restores them using the UUID tags assigned at connection time:

constructor(state: DurableObjectState, env: Env) {
  this.state = state;
  this.env = env;
  this.state.blockConcurrencyWhile(async () => {
    const sockets = this.state.getWebSockets();
    for (const ws of sockets) {
      const tags = this.state.getTags(ws);
      if (tags.length > 0) {
        const role = await this.state.storage.get<string>(`role:${tags[0]}`);
        const userId = await this.state.storage.get<string>(`user:${tags[0]}`);
        // Skip sockets that are not fully open (e.g. CLOSING after replacement)
        if (ws.readyState !== WebSocket.READY_STATE_OPEN) continue;
        if (role === "mac") this.macSocket = ws;
        else if (role === "ios") this.iosSocket = ws;
        if (userId) this.userIdBySocket.set(ws, userId);
      }
    }
    // Restore session timing for duration tracking
    const savedStart = await this.state.storage.get<number>("session_start");
    if (savedStart) this.sessionStartTime = savedStart;
  });
}

blockConcurrencyWhile ensures no incoming messages are processed until restoration is complete.