Skip to main content

Transport degradation

Live uses one protocol and a descending client transport ladder. The component does not need to know which rung is active.

RungTransportWhat it providesRequirement
1WebSocketBidirectional protocol, patches, current, and snapshotsA WebSocket upgrade can reach the live gateway
2Server-Sent EventsDownstream events and patches; client messages by POSTsse: true and a browser EventSource
3Conditional pollingHTTP snapshots and 304 Not Modified responsesLive GET routes plus the generated routes tree
4No JavaScriptThe server-rendered page remains usableNo live client

The first three rungs keep the same resource ids, inputs, hashes, and server messages. Polling is intentionally less expressive: it sends full snapshots when the ETag changes rather than trying to reconstruct a patch history for a client that may have been offline.

Enabling SSE

SSE is opt-in because it adds two public HTTP routes:

app.use(LivePlugin.create({
controllers: [TasksController],
sse: true
}));

By default, the routes are:

  • GET /live/sse — the downstream event stream;
  • POST /live/control — the upstream endpoint for hello, sub, unsub, and resync messages.

The paths, heartbeat interval, and stream limit are configurable with ssePath, sseControlPath, sseHeartbeatMs, and sseMaxConnections in the live config. The stream's connection id is a bearer for that stream while it is open, so deploy the control route with the same network and authentication controls as the rest of the live surface. Never put the id in a URL that a proxy or analytics system will log.

The browser's EventSource receives the server frames. The client posts its protocol messages to the control route and includes the stream id, so the server can associate each POST with the correct SSE connection.

Conditional polling and ETags

ETag support is enabled by default for live GET routes. It can be made explicit or disabled:

app.use(LivePlugin.create({
controllers: [TasksController],
etag: true // default; use false to disable
}));

When the polling rung first subscribes, it calls the live route with a polling marker, a generated connection id, the resource id, and the optional hello token. The server resolves that token and consults LiveAuthorizer before the compiled HTTP route pipeline runs. Resolver-provided scope headers are also replayed into that pipeline. A failed resolver or a 401/403 stops the poll with forbidden; it never becomes a snapshot.

On later ticks it also sends:

If-None-Match: "previous-content-hash"

An unchanged response is 304 Not Modified, so the client keeps the same snapshot object and does not rerender. When the content changes, the route returns JSON with a new ETag, and polling emits a snapshot to the store.

This keeps polling subject to the same live authorization contract as WebSocket and SSE. The token passed to LiveClient is propagated to every poll request; normal HTTP credentials such as same-origin cookies are handled by the browser's fetch implementation.

Only live GET routes are indexed for polling. A live POST handler can still be used over WebSocket or SSE, but it is not a conditional-GET fallback.

Passing the generated routes

The protocol contains a resource id, not an HTTP URL. Polling therefore needs the route tree emitted by @carno.js/client to translate each live resource to its path:

import { LiveClient } from '@carno.js/live/client';
import { routes } from './generated/app';

const client = new LiveClient({
url: 'wss://app.example.com/live',
httpBaseUrl: 'https://app.example.com',
routes,
// Use these only when the server changed config.ssePath or
// config.sseControlPath from their defaults.
ssePath: '/custom/live-stream',
sseControlPath: '/custom/live-control'
});

Without routes, the client cannot construct a polling URL and the ladder stops at SSE. This is deliberate: an invented URL could poll an unrelated endpoint or silently produce incorrect live state.

Descend, do not promote

The ladder descends when a rung fails to open or when its probe times out. Once it has settled on a lower rung, reconnects stay there until the page is reloaded. A proxy that blocks WebSocket is likely to block it on every retry; probing the same failed upgrade before every reconnect would add a round trip to every recovery cycle. A fresh page load starts at WebSocket again, so a temporary deployment issue can recover to the best available transport without making the steady-state degraded client pay for repeated failed probes.