Transport Matrix
Which RPC types work on which server transport. The Connect protocol states: "Bidirectional streaming requires HTTP/2, but the other RPC types also support HTTP/1.1" — a bidi service on an HTTP/1.1 transport does not fail at startup by itself; the first client send simply hangs forever (or the client receives HTTP 505). Connectum turns this into a startup diagnostic — see Startup validation below.
Server transport modes
createServer() picks the transport from tls and allowHTTP1:
| Configuration | Transport | Node server |
|---|---|---|
no tls, allowHTTP1: true (default) | plaintext HTTP/1.1 | http.createServer |
no tls, allowHTTP1: false | plaintext HTTP/2 (h2c) | http2.createServer |
tls configured | TLS + ALPN (HTTP/2; HTTP/1.1 also accepted when allowHTTP1: true) | http2.createSecureServer |
What a client without HTTP/2 sees over TLS
With tls and allowHTTP1: false, the listener serves HTTP/2 only, and what a client that cannot speak HTTP/2 sees depends on its ALPN offer:
A client that offers ALPN protocols but not
h2(for example onlyhttp/1.1) is refused at the TLS layer: the handshake fails with theno_application_protocolalert (ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOLin Node.js).A client that offers no ALPN at all completes the handshake, with no negotiated protocol. If it then sends HTTP/1.1, the server answers with a plaintext reply:
textHTTP/1.0 403 Forbidden Content-Type: text/plain Missing ALPN Protocol, expected `h2` to be available. If this is a HTTP request: The server was not configured with the `allowHTTP1` option or a listener for the `unknownProtocol` event.The text comes from
node:http2, not from Connectum, and the status is403, not a TLS failure. Node.js 22.13.0, Node.js 26.12.1 and Bun 1.4.4 produce the same reply. A load balancer health check or a legacy client that does not offer ALPN hits this response, so point such probes at an HTTP/2 endpoint. WithallowHTTP1: truethe same client is served over HTTP/1.1.
RPC type support
| Transport | Unary | Server streaming | Client streaming | Bidi streaming |
|---|---|---|---|---|
| Plaintext HTTP/1.1 (default) | ✅ | ✅ | ✅ | ❌ blocked at startup |
Plaintext h2c (allowHTTP1: false) | ✅ | ✅ | ✅ | ✅ |
| TLS + ALPN, HTTP/2 negotiated | ✅ | ✅ | ✅ | ✅ |
| TLS + ALPN, HTTP/1.1 negotiated | ✅ | ✅ | ✅ | ❌ hangs at runtime |
Residual risk: TLS with an HTTP/1.1 client
A TLS server with allowHTTP1: true is streaming-capable (HTTP/2 is negotiable), so startup validation does not hard-fail — but a client or intermediary that negotiates HTTP/1.1 over TLS (a client without h2 in its ALPN list, a proxy with an HTTP/1.1 upstream leg) hits the same silent hang on bidi calls. When bidi methods are present on such a server, Connectum logs a one-time warning at startup. Remove the risk entirely by setting allowHTTP1: false (the listener then serves HTTP/2 only), or keep bidi clients on HTTP/2 transports (createGrpcTransport, or createConnectTransport with httpVersion: "2"). Silence the warning with transportValidation: "off".
Pure gRPC protocol needs HTTP/2 even for unary
The matrix above is for the Connect protocol. The classic gRPC protocol (used by grpcurl, gRPC reflection clients, and createGrpcTransport) requires HTTP/2 for every RPC type — on the default plaintext HTTP/1.1 server, gRPC clients and grpcurl do not work at all. Use h2c or TLS.
Serving gRPC and HTTP/1.1 on one plaintext port
A single plaintext (no-TLS) port cannot serve both native gRPC (which needs HTTP/2 / h2c) and plain HTTP/1.1 clients. Per-connection protocol selection in the TLS mode uses ALPN. Node's cleartext servers do not provide automatic selection between an HTTP/1.1 request and an HTTP/2 connection preface. This is a limitation of the Node server APIs used by Connectum: Node core has declined to add cleartext allowHTTP1 (nodejs/node#26795, #44887 — both closed; maintainers prescribe userland byte-sniffing), and Upgrade: h2c is deprecated by RFC 9113. So createServer() offers allowHTTP1: true (HTTP/1.1 only) or false (h2c only) on a plaintext port — never both.
This matters when a reverse proxy / API gateway that speaks HTTP/1.1 (e.g. Ory Oathkeeper, nginx) fronts a service whose internal peers use native gRPC. Resolve it with one of these, in order of preference:
- Put a sidecar / edge proxy in front (recommended — runtime-agnostic). A proxy that multiplexes protocols — Envoy or Caddy — terminates the mixed edge and forwards a single protocol upstream. The proxy does the protocol detection the runtime cannot, and it works the same on every JS runtime (see the matrix below). See Envoy Gateway and Service Mesh.
- Use TLS + ALPN with
allowHTTP1: true. A TLS server serves HTTP/1.1 and HTTP/2 on one port (ALPN negotiates per client). If app-level TLS is acceptable, this is the built-in mixed-port answer. - Two listeners. Serve native gRPC (h2c) and Connect/HTTP-1.1 on separate ports/roles. Lower complexity, but not one port.
Connect and gRPC-Web do not need any of this
Only native gRPC needs HTTP/2. The Connect and gRPC-Web protocols run over HTTP/1.1, so the default plaintext HTTP/1.1 server already serves both — a gateway that downgrades to HTTP/1.1 works for them with no extra setup.
Runtime support for native gRPC
Native gRPC depends on HTTP/2 response trailers (grpc-status). The Bun Bun.serve API does not provide the HTTP/2 server behavior required by native gRPC in the measurements recorded below. Connectum does not use Bun.serve: createServer() builds on node:http2.
| Runtime | Native gRPC server | Connect / gRPC-Web | gRPC + HTTP/1.1 on one plaintext port |
|---|---|---|---|
Node (node:http2 — what Connectum uses) | ✅ | ✅ | ❌ — use a sidecar proxy or TLS + ALPN |
Bun (node:http2 — what Connectum uses) | ✅ | ✅ | ❌ |
Bun (Bun.serve) | ❌ (no HTTP/2 at all)* | ✅ | ❌ |
Deno (Deno.serve) | Not tested | Not tested | Not tested |
| Cloudflare Workers | Not supported by Connectum† | Not supported by Connectum† | Not supported by Connectum† |
* Measured on Bun 1.3.13 and 1.3.14, and the reason is stronger than the missing trailers: Bun.serve has no HTTP/2 server at all. Offered ["h2","http/1.1"] over TLS it selects no protocol (alpnProtocol === false); an h2c prior-knowledge request fails with ERR_HTTP2_ERROR Protocol error; and Response has no trailer member, so a Trailer: header is echoed but nothing follows the body. There is no http2 option to enable -- Bun.serve silently ignores unknown keys, so passing one proves nothing. Bun 1.3.14 adds an HTTP/3 server and an experimental HTTP/2 client for fetch(); neither is an HTTP/2 server.
† Cloudflare Workers is a platform capability, not Connectum support — see Cloudflare Workers below.
This project has not tested the Deno row. It is left unclassified rather than inferred from a response API shape.
Connectum builds on neither API. Everything it does use is covered in Verified behaviour by runtime.
Takeaway: the measured limitation applies to Bun.serve; it does not apply to a Connectum server on Bun: createServer() builds on node:http2, whose server side delivers trailers on Bun as well — including plaintext h2c. Connect over HTTP/1.1 was verified on Node.js and Bun in the tested configurations below. If you write your own Bun.serve handler and must expose gRPC, terminate it at a sidecar proxy (Envoy / Caddy) and let the runtime serve Connect / HTTP-1.1. This project has not validated an equivalent deployment on Deno.
Bun client versions
Serving worked on the Bun versions recorded below, while Bun's node:http2 client only became usable in Bun 1.2.6 — see Runtime Compatibility.
Cloudflare Workers
createServer() is not a Cloudflare Workers entry point, and a Connectum server cannot be deployed to Workers today. createServer() builds its listener on node:http2 and node:http (createSecureServer, http2.createServer, http.createServer), and Cloudflare documents node:http2 in Workers as a non-functional stub that "can be imported or required, but does not provide a working implementation" (Node.js compatibility). Connectum has no Fetch-style handler for Workers. One is planned; nothing in this section describes shipped behavior.
What Cloudflare announces is a platform capability, not a Connectum guarantee. Its announcement describes a private beta in which a Worker speaks gRPC-web and the platform translates it to and from native gRPC. According to that post, Workers can serve unary and server-streaming gRPC APIs; it does not describe client streaming or bidirectional streaming for Workers, and presents full bidirectional streaming only for a gRPC server running in a container. This project has not run Connectum against that beta.
Verified behaviour by runtime
The following records historical measurements, not a new validation of the current peer-dependency line. Every row was run against a Connectum server built by createServer(), with @connectum/core 1.2.0 and @connectrpc/connect-node (both 2.0.0 and 2.1.2), asserting the response payload and the gRPC status code -- not merely that a call did not throw. Each scenario ran three times.
Server side -- createServer()
| Runtime | Plaintext HTTP/1.1 (default) | Plaintext h2c (allowHTTP1: false) |
|---|---|---|
| Node.js | Connect unary ✅ · server streaming ✅ · error status ✅ · native gRPC ❌ (needs HTTP/2) · bidi ❌ refused at startup | unary ✅ · server streaming ✅ · bidi ✅ · gRPC status in trailers ✅ |
| Bun (1.1.38 and up) | identical to Node.js, including the startup refusal | unary ✅ · server streaming ✅ · bidi ✅ · gRPC status in trailers ✅ |
The startup refusal was run on both runtimes: a service with a bidi method on allowHTTP1: true rejects server.start() with CONNECTUM_UNSUPPORTED_STREAMING_TRANSPORT rather than starting and hanging on the first send.
Serving was never the Bun problem. A Connectum h2c server under Bun delivers HTTP/2 trailers in both directions -- verified down to Bun 1.1.38, including an error whose grpc-status arrives only in the trailer. This is because createServer() builds on node:http2; it is not affected by what Bun.serve can or cannot do.
Client side -- @connectrpc/connect-node
| Runtime | createGrpcTransport / createConnectTransport({httpVersion:'2'}) | createConnectTransport() (HTTP/1.1) |
|---|---|---|
| Node.js | unary ✅ · server streaming ✅ · bidi ✅ · trailers ✅ | unary ✅ · server streaming ✅ · bidi ❌ (protocol) |
| Bun <= 1.2.5 | ❌ first RPC hangs -- the transport is constructed without error, the call never completes, nothing is thrown | unary ✅ · server streaming ✅ · bidi ❌ (protocol) |
| Bun >= 1.2.6 | unary ✅ · server streaming ✅ · bidi ✅ · trailers ✅ | unary ✅ · server streaming ✅ · bidi ❌ (protocol) |
The Bun boundary was located by bisection over 1.1.38, 1.2.0, 1.2.5, 1.2.6, 1.2.7, 1.2.8, 1.2.9, 1.2.10, 1.2.15, 1.2.21, 1.3.0, 1.3.13 and 1.3.14: 1.2.5 hangs, 1.2.6 passes, and every later version passes. Bun 1.2.6 rewrote the node:http2 client.
Re-checked on Bun 1.3.14 with no regressions, in-process and cross-process in both directions -- a Connectum server hosted by Bun answering a Node.js gRPC client, and the reverse. The cross-process runs captured a real DATA-then-TRAILERS frame sequence carrying grpc-status, so the trailer claim is a wire observation rather than an inference from a client library.
A hang, not an error
Earlier revisions of these docs described this as a TypeError. That symptom was not reproducible on any tested combination. What actually happens is worse to diagnose: the call simply never settles, so a service waits forever rather than failing fast. If you are pinned below Bun 1.2.6 and see a request that never returns, this is the first thing to check.
The two limitations are different in kind
| Node.js | Bun | |
|---|---|---|
| What | one plaintext port serves HTTP/1.1 or h2c, never both | the node:http2 client was unusable |
| Why | protocol selection needs ALPN, which is a TLS handshake extension; a cleartext socket has no handshake | incomplete node:http2 implementation |
| Scope | server side | client side only |
| Status | Not supported by the Node cleartext APIs used here; see #26795 and #44887 | The recorded client boundary is Bun 1.2.6 |
| Work around it by | TLS + ALPN, or a sidecar proxy | upgrading Bun |
Bidirectional streaming in the Connect protocol requires HTTP/2, independently of the runtime. Connectum rejects a plaintext HTTP/1.1 configuration with mounted bidi methods -- see Startup validation.
Not tested
Stated so the table above is not read as broader than it is:
- TLS + ALPN under Bun. Every run above was plaintext (h2c or HTTP/1.1).
- Bun below 1.1.38.
Deno.serve, and Deno generally.@connectrpc/connect-node1.x (protobuf-es v1). Only 2.x was exercised, so the originalTypeErrorreport cannot be disproved for that generation.
Startup validation
When a mounted application service defines bidi-streaming methods and the effective transport is plaintext HTTP/1.1, server.start() rejects with a TransportValidationError carrying the stable code CONNECTUM_UNSUPPORTED_STREAMING_TRANSPORT, the affected service.method list, and both fixes. Only services passed to createServer() (and enabled) count: a bidi service that is merely declared in the same .proto file, or one contributed by a protocol such as Reflection, does not trip the check.
const server = createServer({
services: [bidiRoutes],
// no TLS + allowHTTP1 default → plaintext HTTP/1.1
});
await server.start();
// ✖ TransportValidationError [CONNECTUM_UNSUPPORTED_STREAMING_TRANSPORT]:
// - acme.v1.ScannerService.StreamCodes (bidi_streaming)
// Fix: allowHTTP1: false (h2c) or configure TLS.Downgrade the check with transportValidation: "warn" (log once, start anyway) or "off" — for example behind an HTTP/2-terminating proxy where the bidi method is intentionally unused.
Learn More
- Security & TLS — TLS configuration, mTLS
- Server Configuration —
createServer()options