Repository navigation
feat(client): duplex stream channels with a send path - #107
Merged
Merged
Conversation
…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.
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
Conventional Commits ValidationPR Title: valid |
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.
Contributor
Conventional Commits ValidationPR Title: valid |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)takeshelloandgoodbyeframes.hellogoes out once the transport reports open, and again after every reconnect, in subscription order and beforeonReconnectfires.goodbyegoes out on release while the socket is up. A transport withoutsend()refuses a hello or goodbye at subscribe time instead of dropping it on the floor.StreamConnectiongains an optionalonOpen, which is how a real WebSocket tells us the handshake finished; a connection without it counts as open the momentconnectreturns.backoff.attemptsmay beInfinity.StreamBindingis now a union. The entity binding everyone generates today gets an optionalkind: 'entity', so every existing table keeps type checking. The newduplexkind 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
writeStreamsemits 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 gainkind: 'entity'.Tests
duplex.test.tscovers 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.tscovers 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.