Skip to content

The Connection

createWireClient — the one secure connection substrate that action, realm, and devtools all ride: identity, transports and fallback, TOFU, and the self-healing link lifecycle.

Everything in @nice-code that talks to another process talks over one kind of connection: a single secure socket with one handshake and one authenticated identity. @nice-code/action runs its request/response lane over it, @nice-code/realm rides it as a frame protocol, devtools observes it — but the connection itself is a @nice-code/wire concern, and createWireClient is how you open one directly.

You reach for 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 are the same substrate, 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)
transports: [{ carrier: 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 transport chain and the crypto identity up front, and connect() performs the dial + handshake. It’s idempotent (concurrent/repeat calls share one attempt) and rejects with connect_exhausted when the whole transport chain fails — a realm-only app awaits it; an app that keeps a protocol on the mux can also let the keep-alive ladder heal a first-attempt failure in the background.

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).
transportsThe carriers to try, in preference order (below).

A transport is a carrier plus whether it runs the secure handshake — { carrier, secure? }. Carriers come from @nice-code/wire: wsCarrier (WebSocket), rtcCarrier (WebRTC), inMemoryCarrier (loopback, for tests and single-process). List several in preference order and the client prefers the first that’s ready, falling through on failure:

import { createWireClient, wsCarrier } from "@nice-code/wire";
createWireClient({
identity: myCoordinate,
peer: serverCoordinate,
storage,
transports: [
{ carrier: wsCarrier(() => ({ url: primaryUrl })) }, // preferred
{ carrier: wsCarrier(() => ({ url: fallbackUrl })) }, // used only if the first is exhausted
],
});

The carrier’s createRequest closure runs per dial, so a dynamic endpoint (a realm that hops lobby → match) is just a closure that reads the current target: wsCarrier(() => ({ url: urlFor(activeMatchId) })). Returning null from it means “no valid endpoint right now” — the keep-alive ladder parks instead of dialing garbage, 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 (request/reply, e.g. HTTP) carrier can’t push. Exchange carriers are an action-lane concern; 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 internalize 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 on Identity & Trust — the page that owns them.

A per-session identity is fine as long as the id is minted fresh too — declare it with ephemeralIdentity: true to acknowledge the pairing and silence the durability warning.

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 rides the connection, the link owns its own reconnection — you don’t re-dial on a drop:

  • Keep-alive. An unexpected drop auto-redials with exponential backoff + jitter (≈1 s → 30 s). Default: on when the mux carries a protocol (a realm), off for a bare/plain link. keepLinkAlive: false opts out.
  • Half-open detection (linkKeepalive). After an idle window the link sends a wire ping; no answer closes it locally, converting 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; this is where transport churn is visible.
  • Teardown. client.releaseLink() stops the keep-alive and drops the link (reusable after); client.dispose() is the permanent stop. client.reconnectLink() forces an immediate re-dial (bind it to a “retry now” button).
const client = createWireClient({
identity: myCoordinate,
peer: serverCoordinate,
storage,
transports: [{ carrier: wsCarrier(() => ({ url })) }],
keepLinkAlive: true,
onLinkEvent: (event) => metrics.count(`link.${event.type}`),
});

The reason the connection is its own layer: action, realm, and devtools are all protocols over the one socket, so the resilience story — handshake, secure session, keep-alive, half-open detection, link events — is written once and every plane inherits it.

  • Realm rides the connection as a frame protocol — connectRealm(realm, { connection: realmConnection(client) }). See Connecting & React.
  • Actions ride it as the request/response lane — connectChannel is createWireClient with an action channel bound on. See Serving & Connecting.
  • Both 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 the same substrate: on Cloudflare, serveWireDurableObject (from @nice-code/wire/platform/cloudflare) serves these connections from a Durable Object — the handshake, the DO-storage crypto identity + TOFU pins, hibernation rehydrate, and keepalive folded into one call, with protocols (a realm) registered on it directly. A realm-only DO imports wire + realm and nothing else, mirroring the client exactly. See Serving a Realm.