Bun.serve: add HTTP/2 server support via ALPN on the TLS listener
Bun.serve({ tls, http2: true }) now offers h2,http/1.1 via ALPN on the existing TLS listener. Connections that negotiate h2 are adopted from the HTTP/1 socket group into an HTTP/2 group and served over RFC 9113 framing with HPACK via lshpack. Everything else continues to speak HTTP/1.1 on the same port. Defaults to off. WebSocket over h2 is not supported.
Bun.serve({
port: 0,
tls: { cert, key },
http2: true,
fetch: req => new Response("hello"),
});
How it's wired
The interface mirrors what http3: true already does, so Rust's AnyResponse/AnyRequest dispatch works unchanged and server/mod.rs doesn't need protocol-specific branches beyond construction.
uWebSockets (packages/bun-uws/src/Http2*.h)
H2App/Http2Context/Http2Response/Http2RequestmirrorHttp3*.Http2Contextis a heap struct with an embeddedus_socket_group_t+ statich2VTable, same shape asHttpContext<SSL>.H2App::create()installs an ALPN select cb on the parent SSLApp's rootsslCtxand each per-SNIdomainCtx(addServerNamepropagates it;sni_cbswapsssl->ctxbefore BoringSSL readsalpn_select_cb, so every ctx needs a copy).HttpContext<true>::onData<IsNodeHttp>checks ALPN at the top of each read; onh2it destructs the H1 per-socket state,us_socket_adopts the socket into the H2 group (kind.dynamic→h2VTable), and dispatches the initial bytes (preface + client SETTINGS). TheSSL*stays on the socket so TLS decryption continues transparently.- Per-connection state (lshpack enc/dec, stream map, flow-control windows, frame-parse accumulator) lives in the adopted socket's ext.
Http2Responseis heap-allocated per stream, teardown deferred tosweep()so callers can touch the handle afterend()/tryEnd()returns (matching H3's lsquicprocess_conns()deferral). - Frame parser handles DATA / HEADERS / CONTINUATION / SETTINGS / WINDOW_UPDATE / PING / RST_STREAM / GOAWAY / PRIORITY per RFC 9113. HPACK via
lshpack(same library the fetch client uses).
C ABI (src/uws_sys/libuwsockets_h2.cpp): uws_h2_* mirrors uws_h3_* 1:1.
Rust (src/uws_sys/h2.rs): mirrors h3.rs minus ListenSocket/listen (H2 shares the TCP listener). AnyResponse/AnyRequest/ResponseKind/UWSResponseKind/SinkID/StartTag/CtxTag/NativePromiseContext::Tag each gain an H2 arm.
MUX const-generic: the last const-generic on RequestContext / HTTPServerWritable / AnyRequestContext / NativePromiseContext changes from const HTTP3: bool to const MUX: u8 (MUX_H1=0, MUX_H2=1, MUX_H3=2). H2 and H3 share every stream-multiplexed semantic branch (connection-specific header filter, no transfer-encoding, end_without_body close flag, onAborted post-completion, TE→400) via IS_MUX = MUX != MUX_H1. Only concrete-type selection (AnyResponse/AnyRequest variant, FetchHeaders::create_from_*, callRoute*, sink name) branches on IS_H2 vs IS_H3.
server/mod.rs wires h2_app alongside h3_app: created against the parent SSLApp in listen() (no separate listen() call), routes mirrored in set_routes(), GOAWAY on graceful stop, force-close on abrupt stop via TemplatedApp::close() walking the H2 group like it walks WS groups, destroyed in deinit.
Tests
test/js/bun/http/serve-http2.test.ts (20 tests): basic GET, POST body + response headers echoed, HPACK header decode, user/static route mirroring, multi-frame >16KiB body with flow control, streamed ReadableStream response, multiplexed requests on one connection, server.requestIP(), HTTP/1.1 fallback on the same listener, trailers, SETTINGS/RST_STREAM/WINDOW_UPDATE protocol-error handling, stop(true) force-close, graceful stop, per-SNI ALPN, and option validation.
serve-http3.test.ts (48 tests) and node-http2.test.js (307 tests) still pass.
Relationship to #29962 and #33487
This supersedes closed PR #29962. The Http2*.h layer is ported forward from that PR with the onData<IsNodeHttp> template rebase and 11-field us_socket_vtable_t; the Rust side is new (that PR was Zig-era).
Open PR #33487 also installs SSL_CTX_set_alpn_select_cb (in us_ssl_ctx_build_raw). This PR's installH2Alpn installs on the already-built SSL_CTX; when #33487 lands, installH2Alpn wins because it runs second, and can be folded into the user-supplied protocol list.
Fixes #14672
[review] gate passed · iteration 2 · 44 files touched
fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/bun/http/serve-http2.test.ts"
ninja: Entering directory `/workspace/bun/build/debug'
[1/167] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 6 sinks, 72 exported symbols
Generating /workspace/bun/build/debug/codegen/JSSink.lut.h from /workspace/bun/build/debug/codegen/JSSink.lut.txt
[2/167] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[3/167] gen cpp.rs (cppbind)
[4/167] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
- ResolveMessage (13 fields)
- BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
- Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
- ResourceUsage (8 fields)
- Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
- CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
... (truncated)
release without fix: 1 FAILED
bun test v1.4.0-canary.1 (4c11ece8f)
test/js/bun/http/serve-http2.test.ts:
(pass) Bun.serve http2: true > request headers decode via HPACK and reach the handler [45.26ms]
(pass) Bun.serve http2: true > HTTP/1.1 still works on the same listener when h2 is enabled [43.06ms]
(pass) Bun.serve http2: true > simple GET over ALPN h2 [48.07ms]
(pass) Bun.serve http2: true > multi-frame response body (>16KiB) with flow control [45.92ms]
(pass) Bun.serve http2: true > http2: true without tls throws [0.13ms]
(pass) Bun.serve http2: true > http2: true with http1: false throws [0.07ms]
(pass) Bun.serve http2: true > multiple requests multiplex on one TLS connection [45.19ms]
(pass) Bun.serve http2: true > server.requestIP(req) returns the peer's textual address [44.67ms]
(pass) Bun.serve http2: true > POST body + response headers echoed [47.49ms]
(pass) Bun.serve http2: true > streamed ReadableStream response arrives intact [51.31ms]
(pass) Bun.serve http2: true > user routes and static routes are mirrored onto the H2 router [52.14ms]
(pass) Bun.serve http2: true > request terminated by a trailer section delivers body to the handler [48.94ms]
(pass) Bun.serve http2: true > a fi
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/bun/http/serve-http2.test.ts"
bun test v1.4.0 (13805dd3b)
test/js/bun/http/serve-http2.test.ts:
(pass) Bun.serve http2: true > multi-frame response body (>16KiB) with flow control [559.72ms]
(pass) Bun.serve http2: true > request headers decode via HPACK and reach the handler [581.69ms]
(pass) Bun.serve http2: true > simple GET over ALPN h2 [632.90ms]
(pass) Bun.serve http2: true > POST body + response headers echoed [601.56ms]
(pass) Bun.serve http2: true > user routes and static routes are mirrored onto the H2 router [594.77ms]
(pass) Bun.serve http2: true > streamed ReadableStream response arrives intact [1004.25ms]
(pass) Bun.serve http2: true > multiple requests multiplex on one TLS connection [1013.63ms]
(pass) Bun.serve http2: true > HTTP/1.1 still works on the same listener when h2 is enabled [1008.44ms]
(pass) Bun.serve http2: true > server.requestIP(req) returns the peer's textual address [1021.48ms]
(pass) Bun.serve http2: true > request terminated by a trailer section delivers body to the handler [1033.26ms]
(pass
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 639ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] gen cpp.rs (cppbind)
[2/125] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 7 sinks, 84 exported symbols
Generating /workspace/bun/build/release/codegen/JSSink.lut.h from /workspace/bun/build/release/codegen/JSSink.lut.txt
[3/125] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[4/125] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
- ResolveMessage (13 fields)
- BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
- Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
- ResourceUsage (8 fields)
- Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
- CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
- FileSystemRouter (5 fields)
- FrameworkFileSystemRoute
... (truncated)
diff hotspot
packages/bun-types/serve.d.ts | 12 +
packages/bun-uws/src/App.h | 26 +
packages/bun-uws/src/Http2App.h | 349 ++++++
packages/bun-uws/src/Http2Context.h | 1118 ++++++++++++++++++++
packages/bun-uws/src/Http2ContextData.h | 23 +
packages/bun-uws/src/Http2Request.h | 121 +++
packages/bun-uws/src/Http2Response.h | 160 +++
packages/bun-uws/src/Http2ResponseData.h | 103 ++
packages/bun-uws/src/HttpContext.h | 81 ++
packages/bun-uws/src/HttpContextData.h | 10 +
src/codegen/generate-jssink.ts | 2 +
src/jsc/FetchHeaders.rs | 6 +
src/jsc/bindings/CookieMap.cpp | 4 +
src/jsc/bindings/NativePromiseContext.h | 2 +
src/jsc/bindings/NodeHTTP.cpp | 57 +
src/jsc/bindings/ServerRouteList.cpp | 15 +
src/jsc/bindings/Sink.h | 3 +-
src/jsc/bindings/ZigGlobalObject.cpp | 26 +
src/jsc/bindings/ZigGlobalObject.h | 15 +-
src/jsc/bindings/bindings.cpp | 26 +
src/jsc/bindings/headers-handwritten.h | 1 +
src/jsc/bindings/headers.h | 30 +
.../bindings/webcore/streams/BunStreamSource.cpp | 1 +
src/runtime/api/NativePromiseContext.rs | 104 +-
src/runtime/bake/DevServer.rs | 4 +-
src/runtime/server/AnyRequestContext.rs | 74 +-
src/runtime/server/FileResponseStream.rs | 4 +-
src/runtime/server/FileRoute.rs | 17 +
src/runtime/server/HTMLBundle.rs | 2 +-
src/runtime/server/NodeHTTPResponse.rs | 3 +
src/runtime/server/RequestContext.rs | 356 ++++---
src/runtime/server/ServerConfig.rs
... (truncated)
gate history · 3 passed · 0 rejected · iteration 2
evidence per changed file
file reads edits tests
packages/bun-types/serve.d.ts 0 0 0
packages/bun-uws/src/App.h 0 0 0
packages/bun-uws/src/Http2App.h 3 4 0
packages/bun-uws/src/Http2Context.h 9 11 0
packages/bun-uws/src/Http2ContextData.h 0 0 0
packages/bun-uws/src/Http2Request.h 1 2 0
packages/bun-uws/src/Http2Response.h 2 2 0
packages/bun-uws/src/Http2ResponseData.h 1 1 0
packages/bun-uws/src/HttpContext.h 0 0 0
packages/bun-uws/src/HttpContextData.h 0 0 0
src/codegen/generate-jssink.ts 0 0 0
src/jsc/FetchHeaders.rs 0 0 0
src/jsc/bindings/CookieMap.cpp 0 0 0
src/jsc/bindings/NativePromiseContext.h 0 0 0
src/jsc/bindings/NodeHTTP.cpp 0 0 0
src/jsc/bindings/ServerRouteList.cpp 0 0 0
(+ 28 more files)