# WebSockets
URL: /docs/building-apps/capabilities/websockets
Markdown: /docs/building-apps/capabilities/websockets.md
Description: Upgrade app API requests to live WebSocket connections.

A Rome App's [HTTP API](/docs/building-apps/capabilities/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 [#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.

```ts
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 [#connection-handlers]

`RomeAppWebSocketHandlers` are per-connection callbacks. All are optional.

| Handler   | Signature                                 | Notes                                                                                      |
| --------- | ----------------------------------------- | ------------------------------------------------------------------------------------------ |
| `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) => void`                           | A 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 [#the-connection-facade]

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

| Member                  | Notes                                                                                                                               |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `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).                                                                   |
| `readyState`            | Standard 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 [#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>`):

```ts
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 [#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:

```ts
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](/docs/building-apps/public-access).

## Connection lifecycle and limits [#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.