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.
The shape
Section titled “The shape”- Declare the push domain in the channel’s
toConnector(shared by both ends). - On the connector, handle those pushes with
connectChannel’sonPush— keyed by action id, typed from the channel. The reply routes straight back over the socket. - On the acceptor, use
server.pushToClient(...)(one client) or a handler’sbroadcast(...)(everyone). The originating client is on any inbound action asaction.context.originClient.
Shared channel
Section titled “Shared channel”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});Connector — handle pushes
Section titled “Connector — handle pushes”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 }; }, },});Acceptor — push back
Section titled “Acceptor — push back”A local handler reads action.context.originClient to know who asked, then pushes to them:
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 }; },});Broadcast to everyone
Section titled “Broadcast to everyone”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" },);Connection-aware cases
Section titled “Connection-aware cases”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 →