Capability Negotiation

Focus

Mobile Node Capability Registration

Capability negotiation is the process by which mobile nodes (Android/iOS clients) register their supported features with the Gateway during connection. The NodeRegistry tracks each node's capabilities, expiration times, and connection metadata to enable feature-aware routing.

Client Type GatewayWsClient with canvas capability fields
Registry NodeRegistry — tracks connected nodes
Connect Params ConnectParams with version & capability flags
Expiration canvasCapabilityExpiresAtMs for time-limited capabilities

Table of Contents

Overview

When a mobile node connects to the Gateway via WebSocket, it includes capability information in its ConnectParams. The Gateway parses these flags, validates the client version, and registers the node's capabilities in the NodeRegistry. This registry is then consulted by the routing layer to determine which nodes can handle specific request types (e.g., canvas rendering, media playback).

🔎 Key insight: Capabilities are not permanent — they have expiration timestamps (canvasCapabilityExpiresAtMs). The Gateway automatically prunes expired capabilities and notifies affected nodes to re-negotiate if they are still connected.

Capability Registration Flow

The following sequence diagram shows the full lifecycle of capability registration, from WebSocket handshake through periodic renewal.

sequenceDiagram
    participant M as Mobile Node
    participant WS as WebSocket Server
    participant GW as Gateway
    participant NR as NodeRegistry
    participant CS as CronService

    M->>WS: WebSocket connect
    WS->>GW: onConnection(socket, request)
    GW->>GW: Parse ConnectParams from query/headers
    GW->>GW: Validate client version

    alt version unsupported
        GW-->>M: Close(4001, "unsupported-version")
    end

    GW->>NR: registerNode(connId, capabilities)
    NR->>NR: Store GatewayWsClient entry
    NR-->>GW: registration confirmed
    GW-->>M: Connected with capabilities acknowledged

    Note over CS: Every 30s
    CS->>NR: pruneExpiredCapabilities()
    NR->>NR: Check canvasCapabilityExpiresAtMs
    alt capabilities expired
        NR->>GW: onCapabilityExpired(connId)
        GW-->>M: Notify capability expired
        M->>GW: Re-send capabilities
        GW->>NR: updateCapabilities(connId, newCaps)
    end

GatewayWsClient Type

The GatewayWsClient type represents a connected WebSocket client with its associated capability metadata. Each client entry tracks the canvas capability status, the host URL for canvas rendering, and the expiration timestamp.

interface GatewayWsClient {
  // Connection identity
  connId: string;
  clientIp: string;
  socket: WebSocket;
  connectedAtMs: number;

  // Canvas capability fields
  canvasCapability: CanvasCapability | null;
  canvasHostUrl: string | null;
  canvasCapabilityExpiresAtMs: number | null;

  // Client metadata
  clientVersion: string;
  platform: "android" | "ios" | "desktop" | "web";
  deviceId: string;

  // State tracking
  lastPingMs: number;
  pendingMessages: number;
  isAlive: boolean;
}

type CanvasCapability = {
  supported: boolean;
  version: number;
  maxWidth: number;
  maxHeight: number;
  formats: ("png" | "webp" | "svg")[];
};
⚠ Platform variance: Android clients report canvas capabilities during initial connection, while iOS clients may defer capability reporting until the first canvas request. The Gateway handles both patterns by allowing canvasCapability to be null initially.

ConnectParams Structure

The ConnectParams object is extracted from the WebSocket handshake request. It carries authentication credentials, client version information, and capability flags that the Gateway uses to build the GatewayWsClient entry.

Parse Connection Parameters

Connection parameters are extracted from a combination of URL query parameters and HTTP headers. The client version and capability flags are required fields; missing values cause the connection to be rejected.

interface ConnectParams {
  // Authentication
  connectAuth: ConnectAuth;
  sessionId?: string;

  // Client identification
  clientVersion: string;
  platform: "android" | "ios" | "desktop" | "web";
  deviceId: string;

  // Capability flags
  capabilities: {
    canvas: boolean;
    canvasVersion?: number;
    canvasHostUrl?: string;
    canvasMaxWidth?: number;
    canvasMaxHeight?: number;
    canvasFormats?: string[];
    canvasExpiresInMs?: number;
  };
}

function parseConnectParams(
  url: URL,
  headers: Headers,
): ConnectParams | { error: string } {
  const version = url.searchParams.get("v");
  if (!version) {
    return { error: "missing-client-version" };
  }
  const platform = url.searchParams.get("platform");
  if (!platform || !["android", "ios", "desktop", "web"].includes(platform)) {
    return { error: "invalid-platform" };
  }
  return {
    connectAuth: extractAuth(headers),
    clientVersion: version,
    platform: platform as ConnectParams["platform"],
    deviceId: url.searchParams.get("deviceId") ?? generateDeviceId(),
    capabilities: {
      canvas: url.searchParams.get("canvas") === "true",
      canvasVersion: parseInt(url.searchParams.get("canvasVersion") ?? "0"),
      canvasHostUrl: url.searchParams.get("canvasHostUrl") ?? undefined,
      canvasMaxWidth: parseInt(url.searchParams.get("canvasMaxWidth") ?? "0"),
      canvasMaxHeight: parseInt(url.searchParams.get("canvasMaxHeight") ?? "0"),
      canvasFormats: url.searchParams.get("canvasFormats")?.split(","),
      canvasExpiresInMs: parseInt(url.searchParams.get("canvasExpiresInMs") ?? "0"),
    },
  };
}

Validate Client Version

The Gateway maintains a minimum supported version. Clients below this version are disconnected with a 4001 close code and must update before reconnecting.

Register in NodeRegistry

Once validated, the client's capabilities are registered in the NodeRegistry. The registry maintains a map of connection IDs to GatewayWsClient entries and emits events when capabilities change.

class NodeRegistry {
  private nodes: Map<string, GatewayWsClient> = new Map();
  private listeners: Map<string, Set<CapabilityListener>> = new Map();

  registerNode(connId: string, params: ConnectParams, socket: WebSocket, clientIp: string): void {
    const client: GatewayWsClient = {
      connId,
      clientIp,
      socket,
      connectedAtMs: Date.now(),
      canvasCapability: params.capabilities.canvas ? {
        supported: true,
        version: params.capabilities.canvasVersion ?? 1,
        maxWidth: params.capabilities.canvasMaxWidth ?? 1920,
        maxHeight: params.capabilities.canvasMaxHeight ?? 1080,
        formats: (params.capabilities.canvasFormats ?? ["png"]) as CanvasCapability["formats"],
      } : null,
      canvasHostUrl: params.capabilities.canvasHostUrl ?? null,
      canvasCapabilityExpiresAtMs: params.capabilities.canvasExpiresInMs
        ? Date.now() + params.capabilities.canvasExpiresInMs
        : null,
      clientVersion: params.clientVersion,
      platform: params.platform,
      deviceId: params.deviceId,
      lastPingMs: Date.now(),
      pendingMessages: 0,
      isAlive: true,
    };
    this.nodes.set(connId, client);
    this.emit("registered", connId, client);
  }

  getNode(connId: string): GatewayWsClient | undefined {
    return this.nodes.get(connId);
  }

  getNodesWithCapability(cap: string): GatewayWsClient[] {
    return Array.from(this.nodes.values()).filter(n => {
      if (cap === "canvas") return n.canvasCapability?.supported === true;
      return false;
    });
  }

  removeNode(connId: string): void {
    const node = this.nodes.get(connId);
    if (node) {
      this.nodes.delete(connId);
      this.emit("removed", connId, node);
    }
  }

  private emit(event: string, connId: string, client: GatewayWsClient): void {
    const set = this.listeners.get(event);
    if (set) set.forEach(fn => fn(connId, client));
  }
}

type CapabilityListener = (connId: string, client: GatewayWsClient) => void;

Capability Expiration & Renewal

The CronService periodically checks canvasCapabilityExpiresAtMs for each registered node. When a capability expires, the node is notified via WebSocket and expected to re-send updated capability flags within a grace period. If the node does not renew, the capability is set to null.

Class Relationship Diagram

This diagram shows how the core types relate to each other in the capability negotiation subsystem.

classDiagram
    class GatewayWsClient {
        +string connId
        +string clientIp
        +WebSocket socket
        +number connectedAtMs
        +CanvasCapability canvasCapability
        +string canvasHostUrl
        +number canvasCapabilityExpiresAtMs
        +string clientVersion
        +string platform
        +string deviceId
        +boolean isAlive
    }

    class ConnectParams {
        +ConnectAuth connectAuth
        +string clientVersion
        +string platform
        +string deviceId
        +CapabilityFlags capabilities
    }

    class CanvasCapability {
        +boolean supported
        +number version
        +number maxWidth
        +number maxHeight
        +string[] formats
    }

    class NodeRegistry {
        -Map nodes
        -Map listeners
        +registerNode()
        +getNode()
        +getNodesWithCapability()
        +removeNode()
        +pruneExpiredCapabilities()
    }

    class GatewayClient {
        +Function connect
        +string connId
        +string clientIp
        +CanvasCapability canvasCapability
    }

    class CronService {
        +start()
        +registerTask()
        +stop()
    }

    ConnectParams --> GatewayWsClient : creates
    GatewayWsClient --> CanvasCapability : has
    NodeRegistry --> GatewayWsClient : manages
    GatewayClient --> CanvasCapability : references
    CronService --> NodeRegistry : prunes expired

GatewayClient Interface

The GatewayClient is a simplified view of a connected client exposed to the routing and request handling layers. It wraps the essential fields from GatewayWsClient without exposing the raw WebSocket or internal state.

FieldTypeDescription
connectFunctionReconnection handler for the client
connIdstringUnique connection identifier
clientIpstringClient IP address (normalized)
canvasCapabilityCanvasCapability | nullCurrent canvas capability or null

Capability Expiration & Renewal

Capabilities are not permanent. The canvasCapabilityExpiresAtMs field tracks when a capability grant expires. The CronService runs a pruning task every 30 seconds to detect and handle expirations.

function pruneExpiredCapabilities(registry: NodeRegistry): number {
  const now = Date.now();
  let pruned = 0;

  for (const [connId, client] of registry.entries()) {
    if (
      client.canvasCapabilityExpiresAtMs !== null &&
      client.canvasCapabilityExpiresAtMs < now
    ) {
      // Mark canvas capability as expired
      client.canvasCapability = null;
      client.canvasCapabilityExpiresAtMs = null;
      client.canvasHostUrl = null;
      pruned++;

      // Notify client to re-negotiate
      if (client.isAlive && client.socket.readyState === WebSocket.OPEN) {
        client.socket.send(JSON.stringify({
          type: "capability-expired",
          capability: "canvas",
          message: "Canvas capability has expired. Please re-send capabilities.",
        }));
      }
    }
  }
  return pruned;
}

// Registered during boot as a CronService task
cronService.registerTask("prune-capabilities", "*/30 * * * * *", () => {
  const count = pruneExpiredCapabilities(nodeRegistry);
  if (count > 0) {
    logger.info(`Pruned ${count} expired capabilities`);
  }
});
✅ Graceful renewal: Clients that receive a capability-expired message can immediately re-send their capability flags without disconnecting. The Gateway updates the NodeRegistry entry in place, preserving the connection and session state.

Edge Cases & Error Handling

ScenarioBehaviorImpact
Missing client versionConnection rejected with error: "missing-client-version"Client must include v query param
Unsupported platformConnection rejected with error: "invalid-platform"Only android, ios, desktop, web allowed
Canvas capability deferred (iOS)canvasCapability set to null initiallyNode not routed canvas requests until reported
Expired capability, node offlineCapability pruned; node marked staleRequests rerouted to other capable nodes
Duplicate connIdPrevious entry evicted, new entry storedOld socket closed with 4002 code
Renewal after expiration graceNew expiration timestamp computed from current timeSeamless capability restoration
🚨 Security: The canvasHostUrl field is validated against an allowlist of trusted canvas hosts. Untrusted URLs are rejected to prevent SSRF attacks through capability registration.

Configuration Reference

ParameterTypeDefaultDescription
capability.minClientVersionstring"1.0.0"Minimum supported client version
capability.canvasHostAllowliststring[][]Allowed canvas host URLs
capability.expirationGraceMsnumber60000Grace period before pruning (60s)
capability.pruneIntervalSecnumber30How often to check for expired capabilities
capability.maxNodesPerDevicenumber3Max concurrent connections per device ID

Directory Map

Source files for this topic
src/gateway/
├── node-registry.ts           ← NodeRegistry class, registerNode(), pruneExpiredCapabilities()
├── ws-client.ts               ← GatewayWsClient interface, CanvasCapability type
├── connect-params.ts          ← ConnectParams interface, parseConnectParams()
├── gateway-client.ts          ← GatewayClient simplified interface
├── cron.ts                    ← CronService, capability pruning task registration
└── protocol/
    └── index.ts               ← ConnectAuth, capability flag constants