An HTTP/1.1 proxy can carry destination HTTP/2 frames inside CONNECT. The proxy need not implement those inner frames, but the client must perform the protocol handoffs in order.

After validating the complete CONNECT reply and retaining its tunnelSocket, add destination TLS and give the established stream to HTTP/2:

import tls from 'node:tls';
import http2 from 'node:http2';

const secureSocket = tls.connect({
  socket: tunnelSocket,
  servername: 'api.example',
  ALPNProtocols: ['h2'],
});
secureSocket.once('error', failRoute);
secureSocket.once('secureConnect', () => {
  if (secureSocket.alpnProtocol !== 'h2') {
    secureSocket.destroy();
    failRoute(new Error('Destination did not negotiate h2'));
    return;
  }
  const session = http2.connect('https://api.example', {
    createConnection: () => secureSocket,
  });
  session.on('error', failRoute);
  useSession(session);
});

createConnection returns the TLS stream already carried by the proxy rather than opening another direct socket. The destination servername supplies SNI and certificate identity, with verification enabled. The ALPN check rejects a destination that did not negotiate h2.

failRoute must clean up the route; useSession owns requests and session closure. Apply connection and operation deadlines around this fragment.

The outer connector still owns host:port CONNECT targeting, proxy authentication, rejection handling and bytes buffered after the headers. Finish those before handing the socket to TLS; an HTTP/2 preface sent straight to an HTTP/1.1 proxy listener starts at the wrong layer.

Allakkat attuumassuteqartut