Skip to content

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.

  1. Put the push domain in the channel’s toConnector (in shared code, so both sides agree).
  2. On the dialing side, answer those pushes with connectChannel’s onPush. It takes one entry per action, typed from the channel. Your reply goes straight back over the same connection.
  3. On the listening side, call server.pushToClient(...) to reach one client, or server.broadcast(...) to reach everyone. To know who originally called you, read the handler’s ctx.origin.
shared.ts
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)
});
client.ts
connectChannel(clientRuntime, appChannel, {
peer: serverCoord,
storage,
carriers: [wsCarrier(wsUrl)],
onPush: {
position_update: async ({ player, x, y }) => {
renderPlayer(player, x, y);
return { acknowledged: true };
},
},
});

A handler reads ctx.origin to find out who called, then pushes to them:

server.ts
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 };
},
});

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. pushToClient and broadcast make one attempt over the live socket, and a client that’s offline isn’t reached. For a single push that needs confirmation, await waitForResultPayload() 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.

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:

idWhat happenedHow it surfaces
not_foundthe client has no live connectionserver.pushToClient(...) throws at the call
send_failedthe connection was there but the send failed (the carrier’s error is kept as originError)await running.waitForResultPayload() rejects
timeoutthe push was sent but the client never answeredawait 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:

server.ts
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.