2026-08-13 22:44:13 +03:00
|
|
|
/**
|
|
|
|
|
* Local end of the dev-server tunnel.
|
|
|
|
|
*
|
|
|
|
|
* Binds a loopback listener on this machine and pipes every connection to a
|
|
|
|
|
* dev server on the OpenChamber host. The point of binding a real local port —
|
|
|
|
|
* rather than serving the remote page under a path on some other origin — is
|
|
|
|
|
* that the page then has its own origin at the root of its own host. Absolute
|
|
|
|
|
* URLs resolve, cookies scope correctly, HMR sockets connect, and nothing has
|
|
|
|
|
* to be rewritten.
|
|
|
|
|
*
|
|
|
|
|
* Lives in the web package because it needs a WebSocket client, which this
|
|
|
|
|
* package already depends on; the desktop shell drives it over IPC.
|
|
|
|
|
*/
|
|
|
|
|
import net from 'node:net';
|
|
|
|
|
import { WebSocket } from 'ws';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* What one connection may buffer while its WebSocket is still connecting.
|
|
|
|
|
*
|
|
|
|
|
* Enough for a request with generous headers, far short of a body worth
|
|
|
|
|
* holding: a local process could otherwise keep writing into a stalled
|
|
|
|
|
* handshake and grow the desktop app's memory without limit.
|
|
|
|
|
*/
|
|
|
|
|
const MAX_PENDING_BYTES = 256 * 1024;
|
|
|
|
|
/** A handshake that has not completed by now is not going to. */
|
|
|
|
|
const HANDSHAKE_TIMEOUT_MS = 15_000;
|
|
|
|
|
|
|
|
|
|
const toWebSocketUrl = (baseUrl, port) => {
|
|
|
|
|
const parsed = new URL('/api/dev-tunnel', baseUrl);
|
2026-08-14 12:45:35 +03:00
|
|
|
// 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}"`);
|
|
|
|
|
}
|
2026-08-13 22:44:13 +03:00
|
|
|
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
|
|
|
parsed.searchParams.set('port', String(port));
|
|
|
|
|
return parsed.toString();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const createDevTunnelClient = ({
|
|
|
|
|
logger = console,
|
|
|
|
|
handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS,
|
|
|
|
|
maxPendingBytes = MAX_PENDING_BYTES,
|
|
|
|
|
} = {}) => {
|
|
|
|
|
/** Keyed by `${baseUrl}|${remotePort}` so repeat opens reuse one listener. */
|
|
|
|
|
const tunnels = new Map();
|
|
|
|
|
|
|
|
|
|
const closeTunnel = (key) => {
|
|
|
|
|
const tunnel = tunnels.get(key);
|
|
|
|
|
if (!tunnel) return false;
|
|
|
|
|
tunnels.delete(key);
|
|
|
|
|
for (const socket of tunnel.sockets) {
|
|
|
|
|
try { socket.destroy(); } catch { /* already gone */ }
|
|
|
|
|
}
|
|
|
|
|
try { tunnel.server.close(); } catch { /* already closing */ }
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
/**
|
|
|
|
|
* Opens (or reuses) a tunnel and resolves with the local port to browse.
|
|
|
|
|
* Rejects if the listener cannot bind; per-connection failures close only
|
|
|
|
|
* that connection, so one failed request cannot take the tunnel down.
|
|
|
|
|
*/
|
|
|
|
|
async open({ baseUrl, port, headers = {} }) {
|
|
|
|
|
const remotePort = Number.parseInt(String(port), 10);
|
|
|
|
|
if (!Number.isInteger(remotePort) || remotePort <= 0 || remotePort > 65535) {
|
|
|
|
|
throw new Error('A valid remote port is required');
|
|
|
|
|
}
|
|
|
|
|
const base = String(baseUrl || '').trim();
|
|
|
|
|
if (!base) throw new Error('A remote base URL is required');
|
|
|
|
|
|
|
|
|
|
const key = `${base}|${remotePort}`;
|
|
|
|
|
const existing = tunnels.get(key);
|
|
|
|
|
if (existing) return { localPort: existing.localPort, reused: true };
|
|
|
|
|
|
|
|
|
|
const target = toWebSocketUrl(base, remotePort);
|
|
|
|
|
const sockets = new Set();
|
|
|
|
|
|
|
|
|
|
const server = net.createServer((socket) => {
|
|
|
|
|
socket.setNoDelay(true);
|
|
|
|
|
sockets.add(socket);
|
|
|
|
|
|
2026-08-14 12:45:35 +03:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-08-13 22:44:13 +03:00
|
|
|
upstream.binaryType = 'nodebuffer';
|
|
|
|
|
let pendingWrites = [];
|
|
|
|
|
let pendingBytes = 0;
|
|
|
|
|
|
|
|
|
|
const handshakeTimer = setTimeout(() => {
|
|
|
|
|
logger.warn?.(`[dev-tunnel] handshake timed out for port ${remotePort}`);
|
|
|
|
|
teardown();
|
|
|
|
|
}, handshakeTimeoutMs);
|
|
|
|
|
|
|
|
|
|
function teardown() {
|
|
|
|
|
clearTimeout(handshakeTimer);
|
|
|
|
|
pendingWrites = [];
|
|
|
|
|
pendingBytes = 0;
|
|
|
|
|
sockets.delete(socket);
|
|
|
|
|
try { socket.destroy(); } catch { /* already gone */ }
|
|
|
|
|
try { upstream.close(); } catch { /* already closing */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
upstream.on('open', () => {
|
|
|
|
|
clearTimeout(handshakeTimer);
|
|
|
|
|
for (const chunk of pendingWrites) upstream.send(chunk);
|
|
|
|
|
pendingWrites = [];
|
|
|
|
|
pendingBytes = 0;
|
|
|
|
|
// The local end was held back while there was nowhere to put its
|
|
|
|
|
// bytes; there is somewhere now.
|
|
|
|
|
socket.resume();
|
|
|
|
|
});
|
|
|
|
|
upstream.on('message', (data) => {
|
|
|
|
|
if (socket.destroyed) return;
|
|
|
|
|
socket.write(data);
|
|
|
|
|
});
|
|
|
|
|
upstream.on('error', (error) => {
|
|
|
|
|
logger.warn?.(`[dev-tunnel] upstream failed for port ${remotePort}: ${error?.message || error}`);
|
|
|
|
|
teardown();
|
|
|
|
|
});
|
|
|
|
|
upstream.on('close', teardown);
|
|
|
|
|
|
|
|
|
|
socket.on('data', (chunk) => {
|
|
|
|
|
// Bytes can arrive before the WebSocket handshake completes; buffering
|
|
|
|
|
// them is what keeps the first HTTP request intact. The buffer is
|
|
|
|
|
// bounded, and the local end is paused rather than trusted to stop.
|
|
|
|
|
if (upstream.readyState === WebSocket.OPEN) {
|
|
|
|
|
upstream.send(chunk);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (upstream.readyState !== WebSocket.CONNECTING) return;
|
|
|
|
|
|
|
|
|
|
pendingWrites.push(chunk);
|
|
|
|
|
pendingBytes += chunk.length;
|
|
|
|
|
if (pendingBytes > maxPendingBytes) {
|
|
|
|
|
logger.warn?.(`[dev-tunnel] dropped a connection that buffered too much for port ${remotePort}`);
|
|
|
|
|
teardown();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
socket.pause();
|
|
|
|
|
});
|
|
|
|
|
socket.on('error', teardown);
|
|
|
|
|
socket.on('close', teardown);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const localPort = await new Promise((resolve, reject) => {
|
|
|
|
|
server.once('error', reject);
|
|
|
|
|
server.listen(0, '127.0.0.1', () => {
|
|
|
|
|
server.off('error', reject);
|
|
|
|
|
const address = server.address();
|
|
|
|
|
if (!address || typeof address === 'string') {
|
|
|
|
|
reject(new Error('Failed to bind a local tunnel port'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
resolve(address.port);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
server.on('error', (error) => {
|
|
|
|
|
logger.warn?.(`[dev-tunnel] listener error for port ${remotePort}: ${error?.message || error}`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tunnels.set(key, { server, sockets, localPort, remotePort, baseUrl: base });
|
|
|
|
|
return { localPort, reused: false };
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
close({ baseUrl, port }) {
|
|
|
|
|
return closeTunnel(`${String(baseUrl || '').trim()}|${Number.parseInt(String(port), 10)}`);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/** Closes every tunnel; used when the desktop switches runtime or quits. */
|
|
|
|
|
closeAll() {
|
|
|
|
|
for (const key of [...tunnels.keys()]) closeTunnel(key);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
list() {
|
|
|
|
|
return [...tunnels.values()].map(({ localPort, remotePort, baseUrl }) => ({ localPort, remotePort, baseUrl }));
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
};
|