WebSockets

Upgrade app API requests to live WebSocket connections.

Agent-readable version: Markdown · llms.txt

A Rome App's HTTP API handler can accept WebSocket upgrades. There is no separate manifest entry: an upgrade request arrives at the same handle(request) as any other request, on the same routes (/api/apps/<appId>/... and /api/app-api/<appId>/...). Your handler recognizes the route and calls upgradeWebSocket from @rome-os/app-runtime.

The host owns the socket end to end — the handshake, keepalive, and teardown. Your code only ever sees a RomeAppWebSocket facade; there is no access to the raw socket.

Accepting an upgrade

Call upgradeWebSocket(request, handlers) inside handle and return its Response. For a genuine upgrade request the host completes the handshake and starts dispatching to your handlers. For any other request it returns 426 Upgrade Required — so you can call it unconditionally on a route and let plain HTTP callers get a clean error.

import {
  upgradeWebSocket,
  type RomeAppApiHandler,
  type RomeAppApiRequest,
  type RomeAppContext,
} from "@rome-os/app-runtime";

export function createApiHandler(ctx: RomeAppContext): RomeAppApiHandler {
  return {
    async handle(request: RomeAppApiRequest): Promise<Response> {
      // GET /api/apps/<appId>/live — the live connection endpoint
      if (request.path[0] === "live") {
        return upgradeWebSocket(request, {
          open: (ws) => ws.send("ready"),
          message: (ws, data) => ws.send(`echo:${data}`),
          close: (code, reason) => ctx.log.info("closed", { code, reason }),
        });
      }
      return Response.json({ error: "not_found" }, { status: 404 });
    },
  };
}

The upgrade request is a normal RomeAppApiRequest: method is "GET", path and query work as usual, identity headers are stripped, and caller is resolved by the host. There is no body.

Watch out: if handle returns a normal Response for an upgrade request without calling upgradeWebSocket, the host rejects the handshake with 426 Upgrade Required. The browser surfaces that as a connection error, not as an HTTP response you can read.

Connection handlers

RomeAppWebSocketHandlers are per-connection callbacks. All are optional.

HandlerSignatureNotes
open(ws) => void | Promise<void>The connection is live. If open throws, the host closes the connection with code 1011.
message(ws, data) => void | Promise<void>data is a string for text frames, Uint8Array for binary frames.
close(code, reason) => void | Promise<void>The connection is already terminal — no ws is passed, and there is nothing left to send.
error(err) => voidA transport error on the connection.

Watch out: close does not receive the connection object. To know which connection closed when several are live, capture the ws handed to open in a closure (or a Map) and pass per-connection handlers built around it.

The connection facade

open and message receive a RomeAppWebSocket — one facade per connection:

MemberNotes
send(data)Send a text (string) or binary (Uint8Array) frame. A no-op once the connection is closing or closed — it never throws for that.
close(code?, reason?)Close the connection. code defaults to 1000 (normal closure).
readyStateStandard WebSocket ready state: 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED.

Keep the facade anywhere you like (e.g. a Set of live connections for broadcasts); after close it stays safe to call — send just does nothing.

Connecting from the browser

The WebSocket URL is the app API base plus your route, with the ws/wss scheme. In a web bundle, build it from getBootstrap().apiBase (which is /api/apps/<appId>):

import { getBootstrap } from "@rome-os/app-web-sdk";

const { apiBase } = getBootstrap();
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${scheme}//${location.host}${apiBase}/live`);

ws.onmessage = (event) => console.log(event.data); // "ready", then echoes
ws.onopen = () => ws.send("ping");

The browser attaches the session cookies to the handshake automatically, so a signed-in dashboard surface connects with no extra auth step. The host enforces a same-origin check on every app WebSocket handshake (browsers send cookies on cross-origin WebSocket upgrades, so this closes cross-site hijacking); a cross-origin page gets 403 and never reaches your handler.

Caller identity

request.caller on the upgrade request is resolved by the host from the same session material as HTTP requests — guardian cookie or trusted loopback, and the Rome Cloud visitor cookie — with guardian winning when both are present. Gate the route before upgrading, exactly like an HTTP route:

if (request.path[0] === "live") {
  if (request.caller.kind !== "guardian") {
    return Response.json({ error: "forbidden" }, { status: 403 });
  }
  return upgradeWebSocket(request, handlers);
}

The caller is fixed for the life of the connection — capture it in the closure around your handlers. For the access tiers and the full caller contract, see Public Access & Caller Identity.

Connection lifecycle and limits

The host manages the transport:

  • Keepalive — the host pings every 30 seconds so idle connections survive reverse-proxy timeouts. You don't implement heartbeats.
  • Frame size — frames over 1 MiB are rejected.
  • Per-app cap — each app is limited to 64 concurrent connections; upgrades beyond that are rejected with 503 during the handshake.
  • App reloads — when your app is reloaded, disabled, or uninstalled (and on host shutdown), the host closes its live connections with code 1012 (Service Restart). A connection never outlives the module instance that owns its handlers — so clients that want a persistent feed should reconnect on close, ideally with backoff.

On this page