Summary
When the proxy runs under Bun, nested hostnames like web.myapp.localhost get the default localhost / *.localhost certificate. A wildcard covers only one label, so browsers reject the name even though portless doctor passes and the local CA is trusted.
Cause: Bun never invokes SNICallback, so createSNICallback never mints per-host certificates (~/.portless/host-certs/ is never created).
How the proxy ends up on Bun
A monorepo whose dev script is bun some-launcher.ts, which runs portless proxy start. The daemon is spawned with process.execPath, so it inherits Bun:
bun …/portless/dist/cli.js proxy start --foreground --port 443 --https --skip-trustRepro
- portless 0.15.6 (the 0.15.7 release notes don't mention this)
- Bun 1.4.2, macOS arm64
bun node_modules/portless/dist/cli.js proxy start --foreground --port 14443 --https --skip-trust
# register any app as web.myapp.localhost, then:
curl --resolve web.myapp.localhost:14443:127.0.0.1 https://web.myapp.localhost:14443/
# curl: (60) SSL: no alternative certificate subject name matches target host nameStandalone check that SNICallback is ignored:
import https from 'node:https'; import tls from 'node:tls'; import fs from 'node:fs';
const cert = fs.readFileSync('server.pem'), key = fs.readFileSync('server-key.pem');
let called = false;
const s = https.createServer({ cert, key, SNICallback: (n, cb) => { called = true; cb(null, tls.createSecureContext({ cert, key })); } });
s.listen(0, () => tls.connect({ port: s.address().port, servername: 'a.b.localhost', rejectUnauthorized: false }, () => {
console.log('SNICallback called:', called); process.exit();
}));
// node: true, bun: falseWhat does work on Bun
server.addContext(hostname, secureContext)is honoured, but only on a server that accepts the socket itself. Sockets handed over withh2Server.emit("connection", socket)(the HTTP/HTTPS sniffing wrapper increateProxyServer) always get the default certificate.- Contexts added with
addContextnegotiate only HTTP/1.1 on Bun (noh2via ALPN, even withALPNProtocolsset). That's a Bun limitation, but HTTP/1.1 with a valid certificate beats HTTP/2 with a rejected one. WebSocket upgrades still work (101).
Workaround we're using (bun patch on 0.15.6, tested)
Only on Bun:
createProxyServerreturnsh2Serverdirectly instead of the sniffing wrapper. Port 443 still gets the HTTP-to-HTTPS redirect from the server on port 80; only plain HTTP on the HTTPS port itself is lost.createSNICallbackexposessni.preload(hostname), which reuses the same cache and generation path.startProxyServerpreloads every route's context at startup and on each route reload, and callsserver.addContext(hostname, ctx)on every listening server.
Node behaviour is unchanged. Patch inline below.
patch against dist (0.15.6)
diff --git a/dist/chunk-SLEZT6EJ.js b/dist/chunk-SLEZT6EJ.js
index 76791c619bf72a8c5b99eb2f086e150b3cef9466..30ae01d691c6ea6c2ec591b14a272d2e259bc366 100644
--- a/dist/chunk-SLEZT6EJ.js
+++ b/dist/chunk-SLEZT6EJ.js
@@ -787,6 +787,9 @@ function createProxyServer(options) {
plainServer.on("upgrade", (req, socket, head) => {
handleUpgrade(req, socket, head);
});
+ // Bun applies addContext() certificates only to sockets its TLS server
+ // accepts itself, not ones handed over via emit("connection").
+ if (process.versions.bun) return h2Server;
const wrapper = net2.createServer((socket) => {
socket.on("error", () => {
socket.destroy();
diff --git a/dist/cli.js b/dist/cli.js
index 648ea06770ed30d1f527478f0889bb047e2a588c..435a6e74a1a078756f277e353a58af34523db4be 100755
--- a/dist/cli.js
+++ b/dist/cli.js
@@ -600,7 +600,7 @@ function createSNICallback(stateDir, defaultCert, defaultKey, tlds = "localhost"
cert: caCert ? Buffer.concat([defaultCert, caCert]) : defaultCert,
key: defaultKey
});
- return (servername, cb) => {
+ const sni = (servername, cb) => {
if (servername === "localhost" && configuredTlds.includes("localhost")) {
cb(null, defaultCtx);
return;
@@ -650,6 +650,10 @@ function createSNICallback(stateDir, defaultCert, defaultKey, tlds = "localhost"
cb(err instanceof Error ? err : new Error(String(err)));
});
};
+ sni.preload = (servername) => new Promise((resolve, reject) => {
+ sni(servername, (err, ctx) => err ? reject(err) : resolve(ctx));
+ });
+ return sni;
}
function trustCA(stateDir) {
const caCertPath = path.join(stateDir, CA_CERT_FILE);
@@ -4601,10 +4605,26 @@ function startProxyServer(store, proxyPort, tld, tlds, tlsOptions, lanIp, strict
}
publishCachedRoutes();
};
+ const primedHosts = /* @__PURE__ */ new Set();
+ const primeHostContexts = () => {
+ const preload = tlsOptions?.SNICallback?.preload;
+ if (!process.versions.bun || !preload) return;
+ for (const { hostname } of cachedRoutes) {
+ if (primedHosts.has(hostname)) continue;
+ primedHosts.add(hostname);
+ preload(hostname).then((ctx) => {
+ for (const s of [server, ...additionalServers]) s.addContext?.(hostname, ctx);
+ }).catch((err) => {
+ primedHosts.delete(hostname);
+ console.error(colors_default.red(`TLS certificate for ${hostname} failed: ${err.message}`));
+ });
+ }
+ };
const reloadRoutes = () => {
try {
const previousRoutes = new Map(cachedRoutes.map((r) => [r.hostname, r.port]));
cachedRoutes = store.loadRoutes();
+ primeHostContexts();
if (autoSyncHosts) {
syncHostsFile(cachedRoutes.map((r) => r.hostname));
}
@@ -4706,6 +4726,7 @@ function startProxyServer(store, proxyPort, tld, tlds, tlsOptions, lanIp, strict
);
});
}
+ primeHostContexts();
if (isTls && proxyPort !== 80) {
for (const bindTarget of bindTargets) {
const redirectServer = createHttpRedirectServer(proxyPort);Possible upstream options: the approach above; or have the daemon prefer node over process.execPath when started under Bun (when it's on PATH); or at least have portless doctor warn when the proxy runs under Bun with HTTPS.
I reproduced this on Bun 1.4.2 on Windows and opened https://github.com/vercel-labs/portless/pull/470 with a compatibility fix. It preloads registered hostname contexts with
addContextand lets Bun accept TLS connections directly, including newly registered routes. The regression verifies real certificates and WSS upgrades; Node keeps the existing listener behavior. Bun-specific limitations are documented in the PR.