Fix http2 route flag silently dropped in toJsRoute()#26
Conversation
toJsRoute() built the JsRouteConfig field-by-field but never copied `http2`, so `http2: true` on a route never reached Rust. Every route defaulted to http2=false (no `h2` in the TLS ALPN advertisement), regardless of config. Both config paths (constructor and updateConfig) share this mapper, so both were affected. Add the missing passthrough, plus end-to-end ALPN regression tests that assert `h2` is negotiated only when http2: true, including via updateConfig. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request fixes a bug where the http2 option was dropped in toJsRoute and never reached the Rust side, preventing HTTP/2 from being advertised in ALPN. It introduces a regression test suite and a tlsAlpn helper function to verify ALPN negotiation. The review feedback correctly identifies a resource leak in the tlsAlpn helper where the setTimeout timer is not cleared upon connection success or failure, and provides a code suggestion to properly manage the timer and destroy the socket on timeout.
| return new Promise((resolve, reject) => { | ||
| const { port, host = '127.0.0.1', servername, caCert, alpnProtocols, rejectUnauthorized = false } = opts; | ||
| const socket = tls.connect( | ||
| { port, host, servername, ca: caCert, ALPNProtocols: alpnProtocols, rejectUnauthorized }, | ||
| () => { | ||
| resolve(socket.alpnProtocol || ''); | ||
| socket.end(); | ||
| }, | ||
| ); | ||
| socket.on('error', reject); | ||
| setTimeout(() => reject(new Error('tlsAlpn timeout')), 5000); | ||
| }); |
There was a problem hiding this comment.
The setTimeout timer is never cleared when the connection succeeds or fails. This can lead to resource leaks, keeping the Node.js event loop active and delaying process exit, or potentially causing unexpected behavior in tests. Additionally, if a timeout occurs, the socket is not destroyed, which could leave a hanging connection.
We should store the timer reference, clear it when the promise resolves or rejects, and destroy the socket on timeout.
return new Promise((resolve, reject) => {
const { port, host = '127.0.0.1', servername, caCert, alpnProtocols, rejectUnauthorized = false } = opts;
let timer: any;
const socket = tls.connect(
{ port, host, servername, ca: caCert, ALPNProtocols: alpnProtocols, rejectUnauthorized },
() => {
clearTimeout(timer);
resolve(socket.alpnProtocol || '');
socket.end();
},
);
timer = setTimeout(() => {
socket.destroy();
reject(new Error('tlsAlpn timeout'));
}, 5000);
socket.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
Problem
toJsRoute()ints/proxy.tsbuilds the napiJsRouteConfigfield-by-field, but never copiedhttp2. Sohttp2: trueon aRouteConfignever reached the Rust side — the object handed to napi simply had nohttp2key, andr.http2.unwrap_or(false)inproxy.rsdefaulted every route tofalse. The result: no route ever advertisedh2in its TLS ALPN extension, regardless of config, and the feature was silently inert.Both config entry points (the constructor and
updateConfig) share this mapper, so both were affected.The rest of the pipeline was already correctly wired — the napi
JsRouteConfigstruct, config parse (proxy.rs:519), the ALPN builder (tls.rs:106), and theterminateTls-required warning. Only the JS→napi hop dropped it.Fix
One-line passthrough in
toJsRoute():sourceAddressHeader: r.sourceAddressHeader, + http2: r.http2,Tests
Added end-to-end ALPN regression coverage in
proxy.spec.ts(+ atlsAlpnhelper): a client offering['h2','http/1.1']negotiatesh2when the route setshttp2: truehttp2is unseth2after enabling it viaupdateConfigBefore the fix, the first case negotiates
''and fails. All 8 tests inproxy.spec.tspass locally against a debug build.🤖 Generated with Claude Code