Skip to content

Bi-directional

Push actions from the acceptor back to the connector over the same socket.

Over a duplex connection (like a WebSocket), the listening side can call the dialing side back on the same open connection — 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 — one entry per action, fully 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 a handler’s broadcast(...) to reach everyone. To know who originally called you, read action.context.originClient.
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,
transports: [{ carrier: wsCarrier(() => ({ url: wsUrl })) }],
onPush: {
position_update: async ({ player, x, y }) => {
renderPlayer(player, x, y);
return { acknowledged: true };
},
},
});

A handler reads action.context.originClient to find out who called, then pushes to them:

server.ts
const lobbyHandler = createLocalHandler().forDomainActionCases(act_lobby, {
start_feed: async (action) => {
let delivered = 0;
for (let seq = 0; seq < action.input.count; seq++) {
const running = server.pushToClient(
action.context.originClient,
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). You can skip the original sender, or only target some connections:

server.broadcast(
() => act_lobby.action.position_update.request({ player: "system", x: 0, y: 0 }),
{ except: originWs, where: (ws) => server.connections.get(ws)?.role === "player" },
);

Pushes are best-effort — even for .reliable() actions. Reliable delivery engages on the dialing side’s sends, not on server pushes: pushToClient and broadcast make one attempt over the live socket, and a client that’s offline simply 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 }), pushBack(request), and connection (the raw socket — it’s null on the HTTP path, which has no live connection).

Advanced — clientEnv (most apps never set this)

Section titled “Advanced — clientEnv (most apps never set this)”

serveChannel takes an optional clientEnv. It’s easy to misread it, so here’s exactly what it is and isn’t.

It is not a filter. Setting clientEnv does not restrict who may connect, and it does not make the acceptor “belong to” one kind of client. One serveChannel always accepts every kind of client, whether you set clientEnv or not.

It does not reach disconnected clients. Pushing to a client that has no live connection simply fails — there’s no queue and no store-and-forward. So clientEnv can’t “deliver to an offline client”; nothing can.

Concretely, the failure surfaces on the push’s running action, not as a silent no-op. await running.waitForResultPayload() rejects: the promise throws a NiceError. Because the failure happens below the action layer (the socket is gone, there’s no declared error for it), it comes back as an unhandled error — error.isUnhandled === true — rather than one of the action’s .throws() errors. Guard it like any other transport failure:

server.ts
import { castNiceError } from "@nice-code/error";
const running = server.pushToClient(originClient, act_lobby.action.position_update.request(pos));
try {
await running.waitForResultPayload();
} catch (e) {
const err = castNiceError(e);
if (err.isUnhandled) {
// client was offline / the connection dropped — nothing was delivered
}
}

What it actually does: every reply and every push to a connected client travels back over the very connection that client is on — automatically, with nothing to configure. clientEnv only comes into play in one narrow setup: when a single runtime runs more than one acceptor (say a WebSocket acceptor and a WebRTC acceptor side by side) and the runtime has to decide which of those sibling acceptors a return should go through. clientEnv labels each acceptor with the kind of client it’s meant for, so that choice is deterministic instead of arbitrary.

So, the rule of thumb:

  • One acceptor on the runtime → leave clientEnv unset. It changes nothing.
  • Multiple acceptors on one runtime, serving different kinds of client → set clientEnv on each, matching the client env it serves, to pin return routing.
// Only meaningful when several acceptors share one runtime:
const server = serveChannel(runtime, appChannel, {
storage: storageAdapter,
handlers: [userHandler],
clientEnv: RuntimeCoordinate.env("frontend"), // this acceptor serves "frontend" clients
carriers: [wsAcceptorCarrier({ send: (ws, frame) => ws.send(frame) })],
});