fix(server): survive stray uncaught exceptions and invalid dev-tunnel base URLs

A single uncaught exception (e.g. a Node-internal socket error) no longer
shuts the local server down; only a sustained storm does. The dev-tunnel
client now rejects non-http(s) base URLs cleanly instead of throwing an
uncaught exception in the connection handler.
This commit is contained in:
Bohdan Triapitsyn
2026-08-14 12:45:35 +03:00
parent b5c9da4ff0
commit 7cf869d5eb
5 changed files with 98 additions and 2 deletions
+20 -1
View File
@@ -27,6 +27,14 @@ const HANDSHAKE_TIMEOUT_MS = 15_000;
const toWebSocketUrl = (baseUrl, port) => {
const parsed = new URL('/api/dev-tunnel', baseUrl);
// WHATWG URL silently ignores a protocol assignment that crosses from a
// non-special scheme (custom app protocols, relay-virtual URLs) to `ws:`.
// Without this check the stale scheme survives into `new WebSocket(...)`,
// which then throws inside the connection handler and takes the whole
// process down; rejecting here fails the open() call cleanly instead.
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`The remote base URL must be http(s); got "${parsed.protocol}"`);
}
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
parsed.searchParams.set('port', String(port));
return parsed.toString();
@@ -76,7 +84,18 @@ export const createDevTunnelClient = ({
socket.setNoDelay(true);
sockets.add(socket);
const upstream = new WebSocket(target, { headers, perMessageDeflate: false });
// A synchronous throw here would be an uncaught exception in the
// connection handler and crash the process; one bad connection must
// fail alone.
let upstream;
try {
upstream = new WebSocket(target, { headers, perMessageDeflate: false });
} catch (error) {
logger.warn?.(`[dev-tunnel] failed to dial upstream for port ${remotePort}: ${error?.message || error}`);
sockets.delete(socket);
try { socket.destroy(); } catch { /* already gone */ }
return;
}
upstream.binaryType = 'nodebuffer';
let pendingWrites = [];
let pendingBytes = 0;
@@ -195,6 +195,15 @@ describe('dev tunnel end to end', () => {
expect(client.list()).toEqual([]);
});
test('rejects a non-http(s) base URL instead of crashing on first connection', async () => {
// A non-special scheme survives the `ws:` protocol assignment (WHATWG URL
// ignores it), so `new WebSocket(...)` used to throw inside the connection
// handler and take the whole process down.
const client = createDevTunnelClient({ logger: { warn: () => {} } });
await expect(client.open({ baseUrl: 'openchamber-ui://index', port: 5173 })).rejects.toThrow('must be http(s)');
expect(client.list()).toEqual([]);
});
// Not covered here: recovery after a request the dev server kills mid-flight.
// The behaviour is real (each connection tears down independently), but the
// abandoned socket makes this harness's teardown unreliable, and a flaky test