Bun.serve: negotiate ALPN over TLS
Bun.serve({ tls }) never negotiated ALPN. Its TLS contexts had no select callback installed, so the server picked no protocol even for a client offering http/1.1, never sent the mandatory no_application_protocol alert on zero overlap (RFC 7301 §3.2), and silently ignored its own tls.ALPNProtocols option, while the identical option on Bun.listen worked.
Every Bun.serve HTTPS endpoint (and therefore node:https.createServer, which is Bun.serve underneath) was invisible to ALPN-based protocol selection: strict-ALPN clients like node:http2 would complete the handshake and then send an h2 preface at an HTTP/1.1 server.
Repro
import tls from "node:tls";
const server = Bun.serve({ port: 0, tls: { cert, key }, fetch: () => new Response("hi") });
const probe = (protocols) => new Promise(res => {
const s = tls.connect({ host: "127.0.0.1", port: server.port, servername: "localhost", ca: cert, ALPNProtocols: protocols },
() => { res(s.alpnProtocol); s.destroy(); });
s.on("error", e => res(e.code));
});
console.log(await probe(["h2", "http/1.1"])); // false (node: "http/1.1")
console.log(await probe(["bogus/9"])); // false (node: ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL)
Cause
us_bun_socket_context_options_t had nowhere to carry an ALPN list, so us_ssl_ctx_build_raw never called SSL_CTX_set_alpn_select_cb and the parsed SSLConfig.protos was dropped. Bun.listen works because it installs a per-connection selector on the SSL itself.
Fix
us_bun_socket_context_options_tcarries the wire-format list.us_ssl_ctx_build_rawcopies it into theSSL_CTXand installs a select callback that runsSSL_select_next_protoand returnsSSL_TLSEXT_ERR_ALERT_FATALon no overlap.- The copy hangs off
SSL_CTXex_data rather than the callback arg: SNI doesSSL_set_SSL_CTX, and BoringSSL readsalpn_select_cb(and through it the list) off the post-SNI context, so each SNI context selects from its own list. Bun.servesets the list when it builds its context options, defaulting tohttp/1.1whentls.ALPNProtocolsis absent. That is the default node'shttps.Serverapplies. An explicit empty list opts out, as onBun.listen.as_usockets()deliberately leavesprotosunset: an ALPN list is only a context property for a server that selects from it, and that conversion is shared withBun.connect,upgradeTLSandSecureContext, which apply theirs per-SSL.BunSocketContextOptions::digest()still coversprotosso a future caller cannot get a falseSSLContextCachehit.tls.ALPNProtocolsis validated where it is parsed. It is raw wire bytes, soALPNProtocols: "h2"(the shape a reader of node's["h2"]reaches for) parses as a 104-byte name and matches nothing. That was inert onBun.servebefore, and once the list is actually consulted it would refuse every client that offers ALPN, with only the alert to go on.Bun.listenandBun.connecthave always had the same trap, so the check sits at the shared parse boundary. An empty list still opts out.
Bun.listen is unchanged: Listener::listen moves protos out of the config before building the context, and its per-connection selector (which also backs ALPNCallback) keeps running.
A server refusing an ALPN offer also made the fetch client report UNKNOWN_CERTIFICATE_VERIFICATION_ERROR, because get_cert_error_from_no was applied to uSockets' EPROTO sentinel rather than an X509 verify code. That is reproducible on main against a real node https server. It now reports HTTP2Unsupported when the offer was h2-only, and ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL otherwise, which is the code node:tls already gives for the same alert.
Verification
Bun.serve, default and explicit, now matches node's https.Server:
node https | before | after | |
|---|---|---|---|
default, offer [h2, http/1.1] | "http/1.1" | false | "http/1.1" |
+ALPNProtocols, offer [h2, http/1.1] | "http/1.1" | false | "http/1.1" |
default, offer [bogus/9] | fatal alert | handshake completes | fatal alert |
Bun.listen +ALPN, offer [h2, http/1.1] | n/a | "http/1.1" | "http/1.1" |
Bun.listen +ALPN, offer [bogus/9] | n/a | fatal alert | fatal alert |
curl --http2 against Bun.serve now negotiates down to 1.1 through ALPN instead of guessing.
New tests in test/js/bun/http/bun-serve-ssl.test.ts cover the default, an explicit wire-format list, an explicit string, the empty list, a client with no ALPN extension, no-overlap rejection, SNI contexts, the fetch error, and four malformed ALPNProtocols values. Most fail with src/ and packages/ reverted to the base; the two that pass both ways are the negative contracts (a client offering no ALPN must still handshake, and an empty list must stay opted out).
test/js/node/http2/node-http2.test.js's "should fail to connect over HTTP/1.1" asserted ERR_HTTP2_ERROR against a Bun.serve TLS server, which was the mis-handshake this fixes. It now asserts the ALPN rejection.
suites run
bun-serve-ssl, serve, bun-server, serve-http3, serve-protocols, node-tls-server, node-tls-connect, node-http2, node-http2-upgrade, fetch, fetch-http2-client, websocket-server, proxy-stress-errors, plus test-http2-alpn.js / test-http2-https-fallback.js / test-http2-server-unknown-protocol.js / test-http2-create-client-secure-session.js. No new failures; the pre-existing ones reproduce identically with src/ and packages/ reverted to the base.
200 TLS servers with SNI entries created and stopped under ASAN/LSAN report zero allocations from the new ex_data copy.
Known gap, not addressed here
node:https.createServer whitelists which TLS keys it forwards to Bun.serve (src/js/node/_http_server.ts, the normalizeServerTls({...}) call) and ALPNProtocols is not among them, so it is still dropped there. Honoring it needs the native TLSOptions to also accept node's string[] form, and Bun.serve cannot speak the h2 a user would most likely ask for, so that belongs in its own change.
SSLConfig.protos is stored as a NUL-terminated C string, so a NUL byte inside a list truncates it. Unreachable for IANA-registered protocol names, which are printable ASCII, and it predates this change.
[review] gate passed · iteration 5 · 13 files touched
fails on main (without fix)
ASAN without fix: 19 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-ssl.test.ts "test/js/node/http2/node-http2.test.js"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (346e474e7)
test/js/bun/http/bun-serve-ssl.test.ts:
(pass) Bun.serve SSL validations > invalid key development [7.73ms]
(pass) Bun.serve SSL validations > invalid key #2 development [2.23ms]
(pass) Bun.serve SSL validations > invalid cert development [1.52ms]
(pass) Bun.serve SSL validations > invalid cert #2 development [6.07ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName development [2.29ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName development [1.89ms]
(pass) Bun.serve SSL validations > invalid key production [2.29ms]
(pass) Bun.serve SSL validations > invalid key #2 production [1.81ms]
(pass) Bun.serve SSL validations > inval
... (truncated)
release without fix: 25 failed, 6 skipped
bun test v1.4.0-canary.1 (1498d7b77)
test/js/bun/http/bun-serve-ssl.test.ts:
(pass) Bun.serve SSL validations > invalid key development [0.32ms]
(pass) Bun.serve SSL validations > invalid key #2 development [0.06ms]
(pass) Bun.serve SSL validations > invalid cert development [0.02ms]
(pass) Bun.serve SSL validations > invalid cert #2 development [0.35ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName development [0.03ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName development [0.01ms]
(pass) Bun.serve SSL validations > invalid key production [0.05ms]
(pass) Bun.serve SSL validations > invalid key #2 production [0.04ms]
(pass) Bun.serve SSL validations > invalid cert production [0.03ms]
(pass) Bun.serve SSL validations > invalid cert #2 production [0.28ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName production [0.01ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName production
(pass) Bun.serve SSL validations > valid development [8.51ms]
(pass) Bun.serve SSL validations > valid 2 development [4.16ms]
(pass) Bun.serve SSL validations > valid production [2.76ms
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-ssl.test.ts "test/js/node/http2/node-http2.test.js"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (346e474e7)
test/js/bun/http/bun-serve-ssl.test.ts:
(pass) Bun.serve SSL validations > invalid key development [8.00ms]
(pass) Bun.serve SSL validations > invalid key #2 development [2.23ms]
(pass) Bun.serve SSL validations > invalid cert development [1.53ms]
(pass) Bun.serve SSL validations > invalid cert #2 development [6.18ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName development [2.34ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName development [1.59ms]
(pass) Bun.serve SSL validations > invalid key production [2.27ms]
(pass) Bun.serve SSL validations > invalid key #2 production [1.79ms]
(pass) Bun.serve SSL validations > inval
... (truncated)
release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
target linux-x64-gnu
build type Release
build dir ./build/release
revision 346e474e71
features (none)
22 deps, 105 codegen, 1168 objects in 758ms
ninja: Entering directory `/workspace/bun/build/release'
[1/1230] gen ErrorCode+*.h
[2/1230] install /workspace/bun
bun install v1.3.14 (0d9b296a)
Checked 124 installs across 170 packages (no changes) [39.00ms]
[3/1230] install /workspace/bun/packages/bun-error
bun install v1.3.14 (0d9b296a)
Checked 1 install across 2 packages (no changes) [6.00ms]
[4/1230] gen bindgenv2
[5/1230] fetch picohttpparser
[picohttpparser] up to date
[6/1230] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1230] fetch tinycc
[tinycc] up to date
[8/1229] gen .bind.ts → GeneratedBindings.cpp
[9/1229] fetch zlib
[zlib] up to date
[10/1229] gen ProcessBindingConstants.lut.h
Ge
... (truncated)
diff hotspot
packages/bun-types/bun.d.ts | 10 ++++
packages/bun-usockets/src/crypto/openssl.c | 70 +++++++++++++++++++++++
packages/bun-usockets/src/libusockets.h | 5 ++
packages/bun-uws/src/App.h | 2 +
src/http/HTTPCertError.rs | 50 +++++++++--------
src/http/HTTPContext.rs | 15 +++++
src/http/ssl_config.rs | 3 +
src/runtime/server/mod.rs | 27 ++++++++-
src/runtime/socket/SSLConfig.rs | 29 +++++++++-
src/runtime/webcore/fetch/FetchTasklet.rs | 3 +
src/uws_sys/SocketContext.rs | 14 +++++
test/js/bun/http/bun-serve-ssl.test.ts | 90 ++++++++++++++++++++++++++++++
test/js/node/http2/node-http2.test.js | 12 +++-
13 files changed, 302 insertions(+), 28 deletions(-)
gate history · 1 passed · 0 rejected · iteration 5
evidence per changed file
file reads edits tests
packages/bun-types/bun.d.ts 2 3 0
packages/bun-usockets/src/crypto/openssl.c 8 10 0
packages/bun-usockets/src/libusockets.h 2 3 0
packages/bun-uws/src/App.h 2 1 0
src/http/HTTPCertError.rs 4 5 0
src/http/HTTPContext.rs 2 4 0
src/http/ssl_config.rs 3 5 0
src/runtime/server/mod.rs 6 8 0
src/runtime/socket/SSLConfig.rs 7 7 0
src/runtime/webcore/fetch/FetchTasklet.rs 1 1 0
src/uws_sys/SocketContext.rs 2 3 0
test/js/bun/http/bun-serve-ssl.test.ts 3 7 0
test/js/node/http2/node-http2.test.js 2 3 0