Skip to content

ADR 0022 — gRPC over ConnectRPC

Accepted · 2026-06-17

Supersedes the gRPC-transport decision (§3) of ADR 0003.

ADR 0003 shipped the first streaming gRPC: Connect-Web spoke HTTP/2 to the upstream directly for server-streaming, the Worker stayed in the unary path, and the desktop data plane ran on @grpc/grpc-js. Client-streaming and bidi were stubbed, and the streaming client hand-encoded Connect envelopes.

That arrangement accumulated real problems on desktop, all rooted in grpc-js:

  1. TLS knobs didn’t work. grpc-js has no equivalent of rejectUnauthorized: false, so the user’s “verify SSL off” setting was silently ignored, and an encrypted client key + passphrase wasn’t handled natively — a correctness gap for a tool that routinely hits self-signed staging and mTLS endpoints.
  2. SSRF IP-pinning was awkward. The other streaming handlers pin a pre-validated IP at connect time (ADR 0006); grpc-js didn’t fit that lookup-based pattern cleanly.
  3. Two descriptor/encoding paths. Web used runtime descriptors + manual Connect envelopes; desktop used grpc-js. The same call was assembled two ways, and only one could do client/bidi streaming.

Adopt ConnectRPC as the single gRPC stack across every backend, driven by one backend-agnostic runtime descriptor registry.

  • One descriptor sourceshared/protocol/grpc-registry.ts builds @bufbuild/protobuf runtime descriptors from reflection or an uploaded .proto / FileDescriptorSet (no codegen); both backends call against it.
  • Web — the shared proxy speaks the Connect protocol (Connect-Protocol-Version: 1) through the Worker for unary + server-streaming; browser-direct streaming uses connect-web.
  • Desktopconnect-node’s createGrpcTransport (native gRPC over real HTTP/2) with an automatic createConnectTransport fallback for Connect-only servers. grpc-js is removed from the data plane.
  • TLS lives in Node’s http2 / tls options, so rejectUnauthorized: false, a custom CA, and an encrypted client cert + passphrase all take effect.
  • SSRF pinning is a nodeOptions.lookup returning the already-validated IP; the authority / SNI stay on the hostname, so certificate validation is unchanged.
  • Client-streaming and bidi now work on desktop (real h2). The web target keeps unary + server-streaming, since browser fetch can’t stream a request body.

Positive

  • “Verify SSL off”, custom CAs, and encrypted client certificates actually take effect for gRPC on desktop.
  • One descriptor registry and one protocol family across web and desktop; the hand-rolled envelope path is gone.
  • Client/bidi streaming is implemented on desktop, closing the ADR 0003 stub.
  • gRPC SSRF pinning is unified with the other streaming handlers’ lookup pattern.

Negative

  • connect, connect-node, and connect-web are added dependencies.
  • Web and desktop differ in streaming capability (web can’t do client/bidi over fetch) — recorded in the capability matrix, not hidden.
  • A desktop call can land on either native gRPC or the Connect fallback depending on the server; the negotiated path is surfaced in the response, but it’s one more thing to reason about.
  • Keep grpc-js. Rejected — no TLS-verification knob, no native encrypted-key handling, an awkward fit for lookup-based SSRF pinning, and a second descriptor/encoding path forever.
  • Generated bufbuild clients. Rejected — Restura is built on runtime proto reflection; codegen breaks the dynamic-descriptor model.
  • node:http2 directly. Rejected — reimplements framing, pooling, and ALPN that connect-node already provides.