Skip to content

fix(client): read a duplex channel's directions from the operations that speak them - #108

Open
juicycleff wants to merge 5 commits into
mainfrom
fix/duplex-message-names
Open

juicycleff wants to merge 5 commits into
mainfrom
fix/duplex-message-names

Conversation

@juicycleff

@juicycleff juicycleff commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #107, two gaps the first real duplex channel exposed.

The generator named forge's own live-query socket as send: '', receive: 'receive', and the two payload schemas were swapped. Both AsyncAPI readers did the same thing: for each operation they walked every message on the channel, stamped it with that operation's action, and let the last-sorted payload win the direction's schema. With the receive operation sorting first, the send direction was never named and the receive schema held the send payload. The URL reader also appended one endpoint per operation, so through a URL source neither half ever carried both schemas and the channel never became a duplex binding at all.

One fold now serves both readers. An operation contributes only the messages it references, or all of them when it lists none, which is what every older document relied on. The first message per direction claims that direction's schema. The messages metadata is keyed by message name, so you get send: 'SendMessage' in the generated table instead of an empty string. Tests drive the fix through both entry points, ParseFile and extractFromAsyncAPI, with the live-query document as forge's router emits it. Against the real twinos document the generated entry is now:

{ kind: 'duplex', channel: '/twinos/api/v1/query/live/ws', send: 'SendMessage', receive: 'ReceiveMessage' }

One visible consequence for URL sources: a channel with several operations used to produce one endpoint per operation, each named after its operation id. It now produces one endpoint, named after the first operation id in sorted order (query.live.wsReceive for the live-query channel). That is what the file reader always did, so the two readers now agree, but if you generated a client from a URL and relied on the per-operation names for a multi-operation channel, the identifier changes.

The second gap is in client-core. cache.live is typed as the structural LiveBinding, and 1.11.0 declared it without raw, so an application's stream helper had to cast to StreamBinder to reach the one method the duplex work added. The interface now carries raw. The type import is erased, so the REST-only bundle does not grow; the size budgets are unchanged.

…hat speak them

Both AsyncAPI readers stamped every message on a channel with each
operation's action and took the last-sorted payload for the direction's
schema. On forge's own live-query channel, where the receive operation
sorts first, that named the send direction nothing, named receive by its
key, and put the send payload in the receive schema. The URL reader also
appended one endpoint per operation, so a duplex channel reached through a
URL source never carried both schemas and never became a duplex binding.

One fold now serves both readers: an operation contributes only the
messages it references (all of them when it lists none), the first message
per direction claims that direction's schema, and the messages metadata is
keyed by message name so the generated binding says `send: 'SendMessage'`.
@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 3:57pm UTC

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 1 follow conventional format

@github-actions github-actions Bot added the fix label Sep 15, 2026
…hannel

`cache.live` is typed as the structural LiveBinding, and 1.11.0 declared it
without `raw`, so an application's stream helper had to cast to the binder
class to reach the one method the duplex work added. The interface now
carries `raw` with the same signature as StreamBinder.raw; the type import
is erased, so the REST-only bundle does not grow.
@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 2 follow conventional format

@github-actions github-actions Bot added fix and removed fix labels Sep 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 2 follow conventional format

@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 2 follow conventional format

@github-actions github-actions Bot added fix and removed fix labels Sep 15, 2026
…hannel

An operation that lists `messages` and resolves none of them to the channel
fell back to speaking every message the channel declares, and then both
operations did, and the fold labelled each name with whichever action came
last. That is the empty send direction this PR exists to remove, arriving by
a different road and without a word of warning.

AsyncAPI 3 says those refs point at the channel's own messages and forge's
router writes them that way, but other generators write
#/components/messages/send and leave the channel to carry the definition.
The trailing segment of the ref is the channel's key there, so try it when
the strict read comes back with nothing. It has to be a key the channel
actually declares, so this can only narrow the fallback, never invent a
message.

When the fallback still engages for an operation that did list refs, the
spec now carries a warning naming the operation and the channel.
applyOperationMessages takes the spec and the operation id to do it, which
both readers already had in hand.

And a name claimed by one direction is no longer relabelled by the other, so
two operations that legitimately speak the whole channel stop taking the
names back from each other and the result no longer depends on operation-id
sort order.
…raw takes

The interface typed the handler as (message: unknown) => void while the
class takes a FrameHandler, which is (message, channel). Method bivariance
means the narrow spelling compiles and the runtime is unaffected: the
channel arrives either way. The caller is what breaks. Anyone reaching the
socket through cache.live, which is the reason this interface exists at all,
receives the channel a frame came on and cannot see it in the type, so on a
socket multiplexing more than one channel they either cast or guess.
@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 4 follow conventional format

…ment

The second resolution pass took the last path segment of any operation
message reference as a key on this channel. A reference into another
channel's messages shares the key vocabulary, so it bound a direction to
the wrong message with no warning. The pass now applies to
#/components/messages/ references only; anything else that resolves
nothing takes the whole-channel fallback and its warning.
@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 5 follow conventional format

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