Skip to content

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.

client.ts
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 live

createWireClient 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:

OptionWhat it is
identityThis client’s RuntimeCoordinate — its authenticated name to the peer. A realm defaults its avatar’s persistentId/instanceId from it.
peerThe acceptor’s coordinate — who you’re dialing.
storageA durable StorageAdapter backing the crypto keypair. Required for a secure connection (the default).
carriersCarrier sources to try, in preference order (below).

The same words apply at every layer:

TermMeaning
identityThis side’s runtime coordinate.
peerThe other side’s runtime coordinate.
connectorThe side that dials.
acceptorThe side that receives a dial.
carrierThe byte path, such as WebSocket, HTTP exchange, WebRTC, or in-memory.
connectionThe 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.

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.

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.

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: false opts 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, or client.addLinkEventListener). link_down, redial_scheduled (attempt + delay — a truthful “reconnecting in N s”), link_up (with downForMs). 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}`),
});

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. connectChannel is createWireClient with 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 connectChannel socket and binds the realm onto it.

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.