Skip to content

Add h2c upstream support for gRPC#242

Open
rodrigovidal wants to merge 1 commit intovercel-labs:mainfrom
rodrigovidal:grpc-support
Open

Add h2c upstream support for gRPC#242
rodrigovidal wants to merge 1 commit intovercel-labs:mainfrom
rodrigovidal:grpc-support

Conversation

@rodrigovidal
Copy link
Copy Markdown

Why

Running a gRPC backend locally under portless currently fails: portless forwards over HTTP/1.1, the backend refuses the connection, and the user sees opaque 502s. This PR adds an opt-in --h2c flag that switches the upstream leg to HTTP/2 cleartext so these backends work
end to end.

Summary

  • Add --h2c flag to tell portless to speak HTTP/2 cleartext to the backend. Required for gRPC and any HTTP/2-only backend.
  • Proxy h2c routes via a new proxyH2c path that preserves bidirectional streaming, response trailers (grpc-status, grpc-message), and the trailers-only wire form (single HEADERS+END_STREAM frame) that some gRPC clients require.
  • Cache an http2.ClientHttp2Session per host:port; clear it on goaway/close/error so the next request reconnects cleanly.
  • Persist the protocol in the route store (protocol: "h2c"), so the proxy dispatches the right path after a restart.
  • Update README.md, skills/portless/SKILL.md, and the --help output per AGENTS.md.

Non-goals

  • Auto-detection. The flag is explicit; portless does not probe backends for HTTP/2 support.
  • HTTPS-to-backend (h2). Scope is cleartext HTTP/2 only, which covers dev servers bound to a plain port.
  • Trailers over HTTP/1.1 downstream. gRPC clients are HTTP/2 in practice; the HTTP/1.1 path uses res.addTrailers best-effort.

Backwards compatibility

The HTTP/1.1 proxy path is unchanged. proxyH2c is dispatched only when a route has protocol: "h2c", which is set solely by the new --h2c flag.

Usage

portless grpc-svc --h2c tsx server.ts

Manual verification

Tested end to end against this branch. The child command itself runs the h2c server on portless's assigned $PORT:

export PORTLESS_PORT=1355 PORTLESS_HTTPS=0 PORTLESS_SYNC_HOSTS=0

# Start an h2c server as the portless child; response echoes :authority
portless grpc-svc --h2c node -e 'require("http2").createServer().on("stream",(s,h)=>{s.respond({":status":200});s.end("authority: "+h[":authority"])}).listen(process.env.PORT)' &

# In another shell
curl --resolve grpc-svc.localhost:1355:127.0.0.1 http://grpc-svc.localhost:1355/
# => authority: grpc-svc.localhost:<backend-port>

The echoed :authority matches <hostname>:<backend-port> from portless list, confirming the request was dispatched through proxyH2c with the correct pseudo-headers.

Test plan

  • 14 new tests in proxy.test.ts covering dispatch, :authority rewrite, Host/hop-by-hop stripping, X-Forwarded-* forwarding, POST body, hops increment, 502 on unreachable backend, session reuse, session reconnect after close, mixed http/h2c routes, non-200 status
    propagation, multi-chunk streaming, gRPC trailers (HTTP/2 client), and gRPC trailers-only wire form.
  • Full suite passes (418 tests); tsc --noEmit and eslint clean.## Why

Running a gRPC backend locally under portless currently fails: portless forwards over HTTP/1.1, the backend refuses the connection, and the user sees opaque 502s. This PR adds an opt-in --h2c flag that switches the upstream leg to HTTP/2 cleartext so these backends work
end to end.

Summary

  • Add --h2c flag to tell portless to speak HTTP/2 cleartext to the backend. Required for gRPC and any HTTP/2-only backend.
  • Proxy h2c routes via a new proxyH2c path that preserves bidirectional streaming, response trailers (grpc-status, grpc-message), and the trailers-only wire form (single HEADERS+END_STREAM frame) that some gRPC clients require.
  • Cache an http2.ClientHttp2Session per host:port; clear it on goaway/close/error so the next request reconnects cleanly.
  • Persist the protocol in the route store (protocol: "h2c"), so the proxy dispatches the right path after a restart.
  • Update README.md, skills/portless/SKILL.md, and the --help output per AGENTS.md.

Non-goals

  • Auto-detection. The flag is explicit; portless does not probe backends for HTTP/2 support.
  • HTTPS-to-backend (h2). Scope is cleartext HTTP/2 only, which covers dev servers bound to a plain port.
  • Trailers over HTTP/1.1 downstream. gRPC clients are HTTP/2 in practice; the HTTP/1.1 path uses res.addTrailers best-effort.

Backwards compatibility

The HTTP/1.1 proxy path is unchanged. proxyH2c is dispatched only when a route has protocol: "h2c", which is set solely by the new --h2c flag.

Usage

portless grpc-svc --h2c tsx server.ts

Manual verification

Tested end to end against this branch. The child command itself runs the h2c server on portless's assigned $PORT:

export PORTLESS_PORT=1355 PORTLESS_HTTPS=0 PORTLESS_SYNC_HOSTS=0

# Start an h2c server as the portless child; response echoes :authority
portless grpc-svc --h2c node -e 'require("http2").createServer().on("stream",(s,h)=>{s.respond({":status":200});s.end("authority: "+h[":authority"])}).listen(process.env.PORT)' &

# In another shell
curl --resolve grpc-svc.localhost:1355:127.0.0.1 http://grpc-svc.localhost:1355/
# => authority: grpc-svc.localhost:<backend-port>

The echoed :authority matches <hostname>:<backend-port> from portless list, confirming the request was dispatched through proxyH2c with the correct pseudo-headers.

Test plan

  • 14 new tests in proxy.test.ts covering dispatch, :authority rewrite, Host/hop-by-hop stripping, X-Forwarded-* forwarding, POST body, hops increment, 502 on unreachable backend, session reuse, session reconnect after close, mixed http/h2c routes, non-200 status
    propagation, multi-chunk streaming, gRPC trailers (HTTP/2 client), and gRPC trailers-only wire form.
  • Full suite passes (418 tests); tsc --noEmit and eslint clean.## Why

Running a gRPC backend locally under portless currently fails: portless forwards over HTTP/1.1, the backend refuses the connection, and the user sees opaque 502s. This PR adds an opt-in --h2c flag that switches the upstream leg to HTTP/2 cleartext so these backends work
end to end.

Summary

  • Add --h2c flag to tell portless to speak HTTP/2 cleartext to the backend. Required for gRPC and any HTTP/2-only backend.
  • Proxy h2c routes via a new proxyH2c path that preserves bidirectional streaming, response trailers (grpc-status, grpc-message), and the trailers-only wire form (single HEADERS+END_STREAM frame) that some gRPC clients require.
  • Cache an http2.ClientHttp2Session per host:port; clear it on goaway/close/error so the next request reconnects cleanly.
  • Persist the protocol in the route store (protocol: "h2c"), so the proxy dispatches the right path after a restart.
  • Update README.md, skills/portless/SKILL.md, and the --help output per AGENTS.md.

Non-goals

  • Auto-detection. The flag is explicit; portless does not probe backends for HTTP/2 support.
  • HTTPS-to-backend (h2). Scope is cleartext HTTP/2 only, which covers dev servers bound to a plain port.
  • Trailers over HTTP/1.1 downstream. gRPC clients are HTTP/2 in practice; the HTTP/1.1 path uses res.addTrailers best-effort.

Backwards compatibility

The HTTP/1.1 proxy path is unchanged. proxyH2c is dispatched only when a route has protocol: "h2c", which is set solely by the new --h2c flag.

Usage

portless grpc-svc --h2c tsx server.ts

Manual verification

Tested end to end against this branch. The child command itself runs the h2c server on portless's assigned $PORT:

export PORTLESS_PORT=1355 PORTLESS_HTTPS=0 PORTLESS_SYNC_HOSTS=0

# Start an h2c server as the portless child; response echoes :authority
portless grpc-svc --h2c node -e 'require("http2").createServer().on("stream",(s,h)=>{s.respond({":status":200});s.end("authority: "+h[":authority"])}).listen(process.env.PORT)' &

# In another shell
curl --resolve grpc-svc.localhost:1355:127.0.0.1 http://grpc-svc.localhost:1355/
# => authority: grpc-svc.localhost:<backend-port>

The echoed :authority matches <hostname>:<backend-port> from portless list, confirming the request was dispatched through proxyH2c with the correct pseudo-headers.

Test plan

  • 14 new tests in proxy.test.ts covering dispatch, :authority rewrite, Host/hop-by-hop stripping, X-Forwarded-* forwarding, POST body, hops increment, 502 on unreachable backend, session reuse, session reconnect after close, mixed http/h2c routes, non-200 status
    propagation, multi-chunk streaming, gRPC trailers (HTTP/2 client), and gRPC trailers-only wire form.
  • Full suite passes (418 tests); tsc --noEmit and eslint clean.

Introduces an optional "protocol" field on RouteInfo ("http" | "h2c").
Routes marked h2c are proxied over cleartext HTTP/2 with a cached
client session per host:port, enabling gRPC and any backend that
only speaks HTTP/2 on its non-TLS listener.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 22, 2026

@rodrigovidal is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant