The Connection
createWireClient opens the one secure connection that actions, realms and devtools share. Covers identity, transports and fallback, TOFU, and the self-healing link lifecycle.
Everything in @nice-code that talks to another process uses one kind of connection: a single secure socket with one handshake and one authenticated identity. @nice-code/action sends its requests and responses over it, @nice-code/realm runs over it as a frame protocol, and devtools observes it. The part that lets them share the socket is the mux (multiplexer): it tags each frame with the protocol it belongs to and hands it to the right one. The connection itself belongs to @nice-code/wire, and createWireClient is how you open one directly.
Use createWireClient when a realm (or any protocol) is the only thing on the socket. A realm-only app needs @nice-code/wire + @nice-code/realm and nothing else. When the app also has actions, connectChannel opens the same connection with an action channel bound on. Both produce the same kind of connection, so a realm’s realmConnection(...) binds to either without knowing which.
Opening a connection
Section titled “Opening a connection”import { createWireClient, wsCarrier } from "@nice-code/wire";
const client = createWireClient({ identity: myCoordinate, // who this client authenticates as peer: serverCoordinate, // the acceptor it dials storage, // durable backing for the crypto identity (see TOFU below) carriers: [wsCarrier(url)],});
await client.connect(); // dial + secure handshake — resolves when the link is livecreateWireClient returns a client handle, not an open socket. It builds the carrier chain and the crypto identity up front, and connect() performs the dial + handshake. connect() is idempotent: concurrent or repeat calls share one attempt. It rejects with connect_exhausted when the whole carrier chain fails. A realm-only app awaits it. When reconnection is on (see The link lifecycle), a failed first connect() still rejects, but the client keeps redialing in the background. An app that started before its backend was ready connects on its own once the backend is up.
The four facts every connection needs:
| Option | What it is |
|---|---|
identity | This client’s RuntimeCoordinate — its authenticated name to the peer. A realm defaults its avatar’s persistentId/instanceId from it. |
peer | The acceptor’s coordinate — who you’re dialing. |
storage | A durable StorageAdapter backing the crypto keypair. Required for a secure connection (the default). |
carriers | Carrier sources to try, in preference order (below). |
Connection vocabulary
Section titled “Connection vocabulary”The same words apply at every layer:
| Term | Meaning |
|---|---|
| identity | This side’s runtime coordinate. |
| peer | The other side’s runtime coordinate. |
| connector | The side that dials. |
| acceptor | The side that receives a dial. |
| carrier | The byte path, such as WebSocket, HTTP exchange, WebRTC, or in-memory. |
| connection | The authenticated lifecycle shared by actions, realms, and other protocols. |
Older low-level members named peerClient, externalClient, peerCoordinate, or
runtimeCoordinate refer to the same peer concept. Compatibility aliases remain in this
release; new code uses peer.
Carriers and the fallback chain
Section titled “Carriers and the fallback chain”A carrier source opens a byte carrier and carries its connection options, including secure. Sources come from @nice-code/wire: wsCarrier (WebSocket), rtcCarrier (WebRTC), and inMemoryCarrier (loopback, for tests and single-process). List several in preference order. The client uses the first that’s ready and falls through to the next on failure:
import { createWireClient, wsCarrier } from "@nice-code/wire";
createWireClient({ identity: myCoordinate, peer: serverCoordinate, storage, carriers: [ wsCarrier(primaryUrl), // preferred wsCarrier(fallbackUrl), // used only if the first is exhausted ],});The carrier’s request closure runs on every dial. A dynamic endpoint, such as a realm that hops from a lobby to a match, reads the current target: wsCarrier(() => activeMatchId == null ? null : urlFor(activeMatchId)). Returning null means “no valid endpoint right now”. The reconnection ladder then parks instead of dialing a bad address, so tearing the target down needs no careful ordering.
A createWireClient connection needs a duplex carrier. A realm (or any mux protocol) is push-driven, and an exchange carrier (request/reply, e.g. HTTP) can’t push. Exchange carriers belong to actions, so use them through connectChannel.
Identity, storage, and security
Section titled “Identity, storage, and security”A secure connection authenticates with a crypto identity your storage holds, and the peer pins it on first contact (trust-on-first-use). The one rule to remember here: storage must be durable. A memory/session store regenerates the keypair each reload, so every load after the first is rejected identity_pin_mismatch, forever. Coordinates, the crypto handshake, the pinning rules, and how to recover a mismatch are all covered on Identity & Trust.
A per-session identity is fine as long as the id is minted fresh too. Declare it with ephemeralIdentity: true; then storage is optional and the client creates an in-memory crypto identity. A supplied link or storage still wins:
createWireClient({ identity: sessionCoordinate, peer, ephemeralIdentity: true, carriers });Pick authenticated (the default) or encrypted with securityLevel. A realm reads the negotiated level off the connection and refuses to attach below its own floor. See Security Levels.
The link lifecycle
Section titled “The link lifecycle”Once a protocol is using the connection, the link owns its own reconnection. You don’t re-dial on a drop:
- Reconnection (
reconnect). An unexpected drop auto-redials with exponential backoff + jitter (≈1 s → 30 s). It is on by default when a protocol such as a realm is registered on the mux. It is off by default for a connection that carries only actions, which reconnects on the next dispatch instead.reconnect: falseopts out. - Half-open detection (
heartbeat). After an idle window the link sends a wire ping. No answer closes it locally, which turns a silently-dead socket into the ordinary redial. - Link events (
onLinkEvent, orclient.addLinkEventListener).link_down,redial_scheduled(attempt + delay — a truthful “reconnecting in N s”),link_up(withdownForMs). A clean heal is silent at a realm’s sync layer, so this is where you see transport churn. - Teardown.
client.releaseLink()stops reconnection and drops the link, and the client is reusable after.client.dispose()is the permanent stop.client.reconnectLink()forces an immediate re-dial, which suits a “retry now” button.
const client = createWireClient({ identity: myCoordinate, peer: serverCoordinate, storage, carriers: [wsCarrier(url)], reconnect: true, onLinkEvent: (event) => metrics.count(`link.${event.type}`),});One substrate, three planes
Section titled “One substrate, three planes”The connection is its own layer because action, realm, and devtools are all protocols over the one socket. The handshake, secure session, reconnection, heartbeat and link events are written once, and all three get them.
- Realm runs over the connection as a frame protocol:
connectRealm(realm, { connection: realmConnection(client) }). See Connecting & React. - Actions use it for requests and responses.
connectChanneliscreateWireClientwith an action channel bound on. See Serving & Connecting. - Devtools is a third protocol on the mux. A devtools window dials the same endpoint and reads the traffic through a token-gated protocol. See Remote Backends.
- Actions and a realm can share one connection: an app with actions and a realm opens a single
connectChannelsocket and binds the realm onto it.
The server twin
Section titled “The server twin”The accepting end is built the same way. On Cloudflare, serveWireDurableObject (from @nice-code/wire/platform/cloudflare) serves these connections from a Durable Object. One call covers the handshake, the DO-storage crypto identity + TOFU pins, hibernation rehydrate, and keepalive, and you register protocols such as a realm on it directly. A realm-only DO imports wire + realm and nothing else, mirroring the client exactly. See Serving a Realm.