Skip to content

feat(client): duplex stream channels with a send path - #107

Merged
juicycleff merged 6 commits into
mainfrom
feat/client-core-duplex-streams
Sep 15, 2026
Merged

juicycleff merged 6 commits into
mainfrom
feat/client-core-duplex-streams

Conversation

@juicycleff

Copy link
Copy Markdown
Contributor

Live queries on the twinos client open their own WebSocket today. They have to: the stream manager is receive-only, and the binding model only knows entity channels. So those sockets get none of the manager's reconnect, revive or gap recovery, and the devtools never see them. This PR adds the two pieces they need, and nothing else moves yet.

client-core

subscribe(channel, handler, options) takes hello and goodbye frames. hello goes out once the transport reports open, and again after every reconnect, in subscription order and before onReconnect fires. goodbye goes out on release while the socket is up. A transport without send() refuses a hello or goodbye at subscribe time instead of dropping it on the floor. StreamConnection gains an optional onOpen, which is how a real WebSocket tells us the handshake finished; a connection without it counts as open the moment connect returns. backoff.attempts may be Infinity.

StreamBinding is now a union. The entity binding everyone generates today gets an optional kind: 'entity', so every existing table keeps type checking. The new duplex kind names a channel the client speaks on with no entity behind it. StreamBinder.raw(channel, handler, options) subscribes such a channel through the manager and hands frames over undecoded; it refuses a channel the generated table does not declare as duplex.

client generator

writeStreams emits a duplex entry for a WebSocket channel that declares both a send and a receive operation and no entity. It reads that straight from the AsyncAPI, so a backend that already describes a channel both ways needs no extension key. Entity entries gain kind: 'entity'.

Tests

duplex.test.ts covers the send path (11 cases: open timing, replay after reconnect and after a repartition, goodbye only while connected, the no-send refusal, unlimited attempts, the snapshot). duplex-binding.test.ts covers the binder. Two generator fixtures pin the duplex emission and the receive-only channel that must stay out. The stream chunk's size budget went from 3.75 kB to 4.2 kB against a measured 4.04 kB.

You'll see the first consumer in twinos-app: its live-query adapter becomes a few lines over raw(), and the socket shows up in the devtools panel like every other one.

…reconnect

subscribe() takes hello and goodbye frames. hello goes out once the
transport reports open and after every reconnect, in subscription
order; goodbye goes out on release while the socket is up. A transport
without send() refuses a hello at subscribe time rather than dropping
it. The snapshot says which channels carry a hello, and attempts may be
Infinity, which the reconnect loop already honoured.
…rd goodbye-only

Three defects from review. First, a first subscribe on a transport
with no onOpen greeted the new hello inside open() and then sent it
again in subscribe() itself; subscribe() now skips the explicit send
when this call is the one that opened the socket. Second, onReconnect
fired right after open() returned even on a transport whose open event
has not happened yet, so a consumer refetching on reconnect could run
before the hello reintroducing the subscription went out; open() now
takes the reconnect's channel list and reports it from the ready path,
after greet(), never before. Third, the no-send guard only checked
hello, so a goodbye-only subscription on a connection without send()
returned normally and later dropped its goodbye in silence; the guard
now applies whenever either option is present.

Updated the gap-recovery and reconnect tests that relied on the old,
premature onReconnect timing to call open() on the reconnected fake
connection, matching what a real transport requires before the manager
can call it ready.
repartition() had the same two defects reconnect() just had fixed.
It called onReconnect right after open(), before the replacement
socket's transport had actually reported open, so a consumer acting
on onReconnect could run before the replacement was greeted. And it
copied channels and refs onto the replacement socket but never
frames, so every surviving subscription's hello and goodbye
registration was silently dropped on an identity change: no hello
went out on the reopened socket and the server never learned the
subscription existed again.

frames now carries over in the same insertion order, and repartition
routes its onReconnect through open()'s report parameter, so it fires
from the ready path after greet(), exactly like a drop-triggered
reconnect.
StreamBinding is now a union: the entity binding everyone generates
today, with an optional kind, and a duplex binding for a channel the
client speaks on with no entity behind it. The binder keeps duplex
channels out of the entity store and exposes raw(channel, handler,
options), which subscribes through the manager with hello and goodbye
frames and refuses any channel the generated table does not declare.

Three existing tests read StreamBinding fields the brief did not list:
frame-ordering.test.ts and overlay.test.ts built entity bindings typed
as the bare union and passed them to a frame() helper typed the same
way, and live.test.ts read .message off a ChannelBindings.bindings
array. All three are narrowed to EntityStreamBinding, the first two by
retyping the fixtures and the helper, the last with an isDuplex filter,
rather than cast.

stream binding measured 4040 B against its 3.75 kB budget once raw()
and isDuplex landed. Raised to 4.2 kB, which clears the measurement
with about the same headroom the query engine's own budget carries.
A WebSocket channel that declares both a send and a receive operation
and no entity is one the client speaks on; the binder takes its frames
raw. writeStreams now emits it with kind 'duplex' and the two message
names, and stamps kind 'entity' on the bindings it already emitted.
The kind is optional on entity bindings in client-core, so every table
generated before this keeps type-checking.
@vercel

vercel Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
forge Ready Ready Preview Sep 15, 2026 1:59pm UTC

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 5 follow conventional format

The bindings table read message, entity, intent and invalidates off every
binding. With StreamBinding now a union that no longer compiles, and at
runtime the first duplex binding threw and blanked the whole tab. The table
narrows on kind: an entity row keeps its columns and a duplex row shows the
send and receive message names with the entity columns empty.

The socket snapshot rows now carry hello, so the live-panel test expects
it, and the streams test narrows its binding assertion for the union.
@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 6 follow conventional format

@github-actions github-actions Bot added feature and removed feature labels Sep 15, 2026
@juicycleff
juicycleff merged commit d5c970e into main Sep 15, 2026
35 checks passed
@juicycleff
juicycleff deleted the feat/client-core-duplex-streams branch September 15, 2026 14:09

This branch was successfully deployed

1 active deployment
Preview — 14045dc2 Deployed Sep 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant