import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import {
createCompositeTransport,
createConnectWebSocketTransport,
} from "@sudorandom/connect-bidi-web";
import { ElizaService } from "./gen/connectbidi/eliza/v1/eliza_pb.js";
const transport = createCompositeTransport(
// Unary RPCs: plain Connect over HTTP.
createConnectTransport({ baseUrl: "https://localhost:4433" }),
// Streaming RPCs: multiplexed onto one shared WebSocket connection.
// connectionPerStream: true dials a connection per RPC instead,
// trading a handshake for freedom from head-of-line blocking.
createConnectWebSocketTransport({
baseUrl: "https://localhost:4433",
}),
);
const client = createClient(ElizaService, transport);
for await (const res of client.converse(requests())) {
console.log(res.sentence); // full bidi streaming!
}
connect-bidi-web
Full bidirectional streaming for connect-go and connect-es, over WebSockets and WebTransport.
Connect's HTTP protocol can't carry full-duplex streams in the browser. These transports can, and they plug into the server and client you already have.
github.com/sudorandom/connect-bidi-web
on pkg.go.dev,
@sudorandom/connect-bidi-web
on npm.
What it is
Transports for
connect-go
and
connect-es
that carry the Connect envelope protocol over a full-duplex
connection instead of HTTP. They use connect-go v2's
Transport API
and the connect-es transport interface, so they go where the HTTP
transport would.
WebSocket
One shared connection, any number of concurrent RPCs. Every frame carries a stream ID so responses find their caller. Runs over TCP, so it goes through the proxies, load balancers, gateways, and serverless platforms that already carry your HTTP traffic.
WebTransport
One HTTP/3 session per client, one QUIC stream per RPC. Multiple concurrent streams share a single session with no head-of-line blocking between them.
You probably can't rely on this 'just working'. WebTransport needs HTTP/3 over UDP all the way to your handler, which rules out most L7 load balancers, gateways, and serverless platforms. Use WebSocket unless you control the whole path and have tested it. See where each transport works.
Composite transport
Keeps unary RPCs on plain HTTP for caching, observability, and proxy support, and routes only streaming RPCs over the bidi transport.
In the browser: connect-es
Client transports for the browser, used by the live demo below
with the same ElizaService.
import { createConnectWebTransportTransport } from "@sudorandom/connect-bidi-web";
// One HTTP/3 session, reused for every streaming RPC.
const session = new WebTransport("https://localhost:4433/webtransport");
const transport = createCompositeTransport(
createConnectTransport({ baseUrl: "https://localhost:4433" }),
createConnectWebTransportTransport({
baseUrl: "https://localhost:4433",
session,
}),
);
On the backend: connect-go
Server handlers for both transports, plus a Go client transport,
built on connect-go v2's
Transport API.
go get github.com/sudorandom/connect-bidi-web
server := connect.NewServer()
elizav1connect.RegisterElizaServiceHandler(server, elizaServer{})
// One WebSocket connection, many concurrent RPCs (demuxed by stream
// ID), over a plain net/http handler.
http.Handle("/websocket", connectwebsocket.NewHandler(server))
transport := connectwebsocket.NewTransport("wss://api.example.com/websocket")
client := elizav1connect.NewElizaServiceClient(connect.NewClient(transport))
stream := client.Converse(ctx)
// stream.Send(...), stream.Receive(), full duplex.
server := connect.NewServer()
elizav1connect.RegisterElizaServiceHandler(server, elizaServer{})
handler := connectwebtransport.NewHandler(server)
// WebTransport rides HTTP/3: sessions arrive over UDP + TLS.
mux := http.NewServeMux()
wtServer := &webtransport.Server{
H3: &http3.Server{Addr: ":443", Handler: mux, TLSConfig: tlsConfig},
}
// One HTTP/3 session per client, one QUIC stream per RPC.
mux.Handle("/webtransport", handler.UpgradeHandler(wtServer))
log.Fatal(wtServer.ListenAndServe())
dialer := &webtransport.Transport{
TLSClientConfig: &tls.Config{NextProtos: []string{http3.NextProtoH3}},
}
_, session, err := dialer.Dial(ctx, "https://api.example.com/webtransport", nil)
transport := connectwebtransport.NewTransport(session)
client := elizav1connect.NewElizaServiceClient(connect.NewClient(transport))
Live demo
Bidirectional streaming against a live Eliza service. The dropdown controls which transport carries the streams.
Run it yourself
The whole demo lives in the repo: this site, the Go server, and the Cloudflare Workers variant.
git clone https://github.com/sudorandom/connect-bidi-web
cd connect-bidi-web
mise install # dev tools: go, node, buf, just, mkcert, wrangler, ...
just demo # Go server: https://localhost:4433
# Connect HTTP + WebSocket + WebTransport
just demo-worker # Cloudflare workerd: http://localhost:8787
# Connect HTTP + WebSocket (no WebTransport on Workers)
The first run of just demo creates a
locally-trusted TLS certificate with
mkcert.
Browsers hold WebTransport certificates to stricter rules
than HTTPS, so local WebTransport needs one tweak on top:
-
In Chrome, enable
chrome://flags/#webtransport-developer-mode. Without it, WebTransport ignores locally-installed roots like mkcert's. -
In Firefox, open
about:configand setnetwork.http.http3.disable_when_third_party_roots_foundtofalse.
Each server serves this same page, with the live demo above talking to whichever one you opened.
Where each transport works
| Transport | Browsers | Infrastructure |
|---|---|---|
| WebSocket | Everywhere | Nearly universal Passes through proxies, load balancers, and edge platforms (Cloudflare Workers, Vercel, …) |
| WebTransport | Chrome 97+, Edge 98+, Firefox 114+, Safari 26.4+ In every major browser since March 2026 | Patchy Needs an end-to-end HTTP/3 (UDP) path; most load balancers, gateways, and edge platforms can't carry it yet |
The demo above checks both ends before offering WebTransport: the browser has to expose the API, and the server has to answer over HTTP/3. If either can't, the dropdown option is disabled.
How the protocol works
One rule: stay as close to the Connect protocol as possible, and add only what a raw socket cannot provide itself.
On the wire, a streaming Connect RPC is a sequence of envelopes:
a flag byte, a big-endian length, and a payload. connect-bidi-web
keeps all of it, byte for byte: the same codecs, the same
per-message compression, the same error codes and details, the
same EndStreamResponse JSON that ends every stream.
A Connect server could almost be fooled.
What it can't keep is everything Connect quietly inherits from
HTTP. Once a WebSocket finishes its upgrade, HTTP leaves the
room: there are no more per-request headers, no way to tell
concurrent RPCs apart, no half-close, and no
RST_STREAM to cancel one call without killing the
rest. Each of those gaps becomes one small, explicit piece of
protocol:
Headers frame (0x06)
A new envelope type opens every stream, carrying what would
have been the HTTP headers as JSON metadata: the
:path of the procedure, the content type,
compression negotiation, deadlines.
Stream ID
Every WebSocket frame is prefixed with a 4-byte stream ID so many RPCs can share one connection. The receiver uses it to match each message to the appropriate caller. Clients assign IDs starting at 1 and never reuse them, exactly the job HTTP/2 stream IDs do for Connect over HTTP.
End-stream envelope (0x02)
Straight from Connect: the response stream ends with the
standard EndStreamResponse JSON. Requests reuse
it with an empty payload as an explicit half-close
(“done sending, still listening”), since neither
the stream nor the WebSocket has one of its own.
Reset frame (0x07)
Cancellation. Closing a shared connection would kill every
RPC on it, and a client that already half-closed has no
other frame left to send. So, like HTTP/2's
RST_STREAM, a reset aborts exactly one stream
and the server cancels that handler's context.
┌───────────┐┌──────┐┌────────────┐┌────────────────┐
│ stream ID ││ flag ││ length ││ payload … │
│ (4 bytes) ││ (1) ││ (4 bytes) ││ │
└───────────┘└──────┘└────────────┘└────────────────┘
└──── a standard Connect envelope ─────┘
| Flag | Frame |
|---|---|
0x00 |
data |
0x01 |
compressed data |
0x02 |
end-stream |
0x06 |
headers |
0x07 |
reset |
WebTransport needs almost none of this. QUIC already gives every RPC its own independently flow-controlled stream, with native half-close and cancellation, so there the protocol is exactly Connect envelopes plus the headers frame, and nothing else. The stream ID and reset frame exist only where the transport can't do that job itself.
Sharing one ordered TCP connection has a real cost, though:
head-of-line blocking. A huge message on one
stream, or a stalled consumer, delays every frame queued behind
it, and unlike HTTP/2 there is no per-stream flow control. For RPCs that can't tolerate that, both clients take an
option (connectionPerStream in TypeScript,
WithConnectionPerStream() in Go) that dials a
dedicated WebSocket connection per streaming RPC instead. It's
purely a client-side choice: the protocol is identical either
way, and a dedicated connection is just a multiplexed connection
that happens to carry one stream.