Bi-directional
Let the server call the client back over the same open connection.
Over a duplex connection such as a WebSocket, the listening side can call the dialing side back on the same open connection. There’s no second connection and no polling for updates.
How it works
Section titled “How it works”- Put the push domain in the channel’s
toConnector(in shared code, so both sides agree). - On the dialing side, answer those pushes with
connectChannel’sonPush. It takes one entry per action, typed from the channel. Your reply goes straight back over the same connection. - On the listening side, call
server.pushToClient(...)to reach one client, orserver.broadcast(...)to reach everyone. To know who originally called you, read the handler’sctx.origin.
Shared channel
Section titled “Shared channel”export const act_lobby = act_app.createChildDomain({ domain: "act_lobby", actions: { start_feed: actionSchema() .input({ schema: v.object({ count: v.number() }) }) .output({ schema: v.object({ delivered: v.number() }) }), position_update: actionSchema() .input({ schema: v.object({ player: v.string(), x: v.number(), y: v.number() }) }) .output({ schema: v.object({ acknowledged: v.boolean() }) }), },});
export const appChannel = defineChannel({ toAcceptor: [act_user, act_lobby], // start_feed goes this way (client → server) toConnector: [act_lobby], // position_update pushes back this way (server → client)});Dialing side — answer the pushes
Section titled “Dialing side — answer the pushes”connectChannel(clientRuntime, appChannel, { peer: serverCoord, storage, carriers: [wsCarrier(wsUrl)], onPush: { position_update: async ({ player, x, y }) => { renderPlayer(player, x, y); return { acknowledged: true }; }, },});Listening side — push back
Section titled “Listening side — push back”A handler reads ctx.origin to find out who called, then pushes to them:
const lobbyHandler = act_lobby.wrapAsLocalHandler({ start_feed: async ({ count }, ctx) => { let delivered = 0; for (let seq = 0; seq < count; seq++) { const running = server.pushToClient( ctx.origin, act_lobby.action.position_update.request({ player: "alice", x: 1, y: 2 }), ); await running.waitForResultPayload(); // wait for the client's reply, just like any action delivered++; } return { delivered }; },});Sending to everyone at once
Section titled “Sending to everyone at once”Use server.broadcast to push to every connected client (fire-and-forget). where narrows it to some connections, and except skips one connection you already hold:
server.broadcast( () => act_lobby.action.position_update.request({ player: "system", x: 0, y: 0 }), { where: (ws) => server.connections.get(ws)?.role === "player" },);Inside a handler you usually want to skip the caller. A channelCases case does that with conn.broadcast(..., { exceptSelf: true }), which is the same call with except filled in for you.
Pushes are best-effort, even for
.reliable()actions. Reliable delivery applies to what the dialing side sends, not to server pushes.pushToClientandbroadcastmake one attempt over the live socket, and a client that’s offline isn’t reached. For a single push that needs confirmation, awaitwaitForResultPayload()and retry. For a push stream that must all arrive, see the direction note for the persisted-log pattern.
When a handler needs the connection itself
Section titled “When a handler needs the connection itself”Sometimes a handler needs the actual connection, to add it to a room or to remember some per-connection state. For that, pass channelCases to serveChannel / serveDurableObject. Each case gets the request and an IConnectionContext:
channelCases: { join: (action, conn) => { conn.setState(action.input); // remember typed state for this connection conn.broadcast(() => act_lobby.action.player_joined.request(action.input), { exceptSelf: true }); return { players: roster() }; },}conn gives you state / setState / clearState, broadcast({ exceptSelf, where }), pushBack(request), and connection. That is the raw socket, and it’s null on the HTTP path, which has no live connection.
Push failure behavior
Section titled “Push failure behavior”Every reply and push to a connected client routes over the connection that client arrived on. Pushing to a client without a live connection fails; there is no queue or store-and-forward behavior.
The failure isn’t silent. Every way a push can fail is an err_nice_transport error, told apart by id:
| id | What happened | How it surfaces |
|---|---|---|
not_found | the client has no live connection | server.pushToClient(...) throws at the call |
send_failed | the connection was there but the send failed (the carrier’s error is kept as originError) | await running.waitForResultPayload() rejects |
timeout | the push was sent but the client never answered | await running.waitForResultPayload() rejects |
None of these is one of the action’s .throws() errors. Keep both calls inside one try, and one guard covers all three:
import { err_nice_transport } from "@nice-code/action";
try { const running = server.pushToClient(originClient, act_lobby.action.position_update.request(pos)); await running.waitForResultPayload();} catch (e) { if (!err_nice_transport.isExact(e)) throw e; // the client was offline, or the connection dropped before it replied. Nothing was confirmed. // Branch further with e.hasId("not_found") / "send_failed" / "timeout" if you need to.}This is a different failure from the dialing side’s initialization_failed, which Error Handling covers.