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
handlereturns a normalResponsefor an upgrade request without callingupgradeWebSocket, the host rejects the handshake with426 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.
| 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:
closedoes not receive the connection object. To know which connection closed when several are live, capture thewshanded toopenin a closure (or aMap) and pass per-connection handlers built around it.
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
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
503during 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.