Skip to content

Bi-directional

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

Over a duplex carrier (a WebSocket) the acceptor can call the connector back on the same open connection — no second channel, no polling.

  1. Declare the push domain in the channel’s toConnector (shared by both ends).
  2. On the connector, handle those pushes with connectChannel’s onPush — keyed by action id, typed from the channel. The reply routes straight back over the socket.
  3. On the acceptor, use server.pushToClient(...) (one client) or a handler’s broadcast(...) (everyone). The originating client is on any inbound action as action.context.originClient.
shared.ts
export const lobbyDomain = appRoot.createChildDomain({
domain: "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: [userDomain, lobbyDomain], // start_feed flows here
toConnector: [lobbyDomain], // position_update pushes back here
});
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 local handler reads action.context.originClient to know who asked, then pushes to them:

server.ts
const lobbyHandler = createLocalHandler().forDomainActionCases(lobbyDomain, {
start_feed: async (action) => {
let delivered = 0;
for (let seq = 0; seq < action.input.count; seq++) {
const running = server.pushToClient(
action.context.originClient,
lobbyDomain.action.position_update.request({ player: "alice", x: 1, y: 2 }),
);
await running.waitForResultPayload(); // await the client's ack like any action
delivered++;
}
return { delivered };
},
});

Fan one out to every client on the sole duplex carrier with server.broadcast (fire-and-forget; skip the origin or filter by connection):

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

When a handler needs the originating connection itself — to register it in a room, track per-socket state — pass channelCases to serveChannel / serveDurableObject. Each case receives the request and an IConnectionContext:

channelCases: {
join: (action, conn) => {
conn.setState(action.input); // typed per-socket state
conn.broadcast(() => lobbyPush.action.player_joined.request(action.input), { exceptSelf: true });
return { players: roster() };
},
}

conn exposes state / setState / clearState, broadcast({ exceptSelf }), pushBack(request), and connection (the raw socket, null on the HTTP-exchange path).

Next: Security levels →