122 lines
4.2 KiB
JavaScript
122 lines
4.2 KiB
JavaScript
import net from 'node:net';
|
|||
|
|
import { randomUUID } from 'node:crypto';
|
||
|
|
|
||
|
|
const CONNECTION_READY_TIMEOUT_MS = 15_000;
|
||
|
|
|
||
|
|
const listen = (server) => new Promise((resolve, reject) => {
|
||
|
|
server.once('error', reject);
|
||
|
|
server.listen(0, '127.0.0.1', () => {
|
||
|
|
server.off('error', reject);
|
||
|
|
const address = server.address();
|
||
|
|
const port = Number(address?.port);
|
||
|
|
if (!Number.isInteger(port) || port <= 0) {
|
||
|
|
reject(new Error('Failed to bind a local relay tunnel port'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
resolve(port);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
const messageData = (event) => {
|
||
|
|
if (event?.type === 'ready' || event?.type === 'data' || event?.type === 'close') return event;
|
||
|
|
return event?.data ?? null;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const createRelayDevTunnelBridge = ({ createMessageChannel, logger = console } = {}) => {
|
||
|
|
const tunnels = new Map();
|
||
|
|
|
||
|
|
const closeTunnel = (key) => {
|
||
|
|
const tunnel = tunnels.get(key);
|
||
|
|
if (!tunnel) return false;
|
||
|
|
tunnels.delete(key);
|
||
|
|
for (const connection of tunnel.connections.values()) connection.close();
|
||
|
|
try { tunnel.server.close(); } catch { /* already closing */ }
|
||
|
|
return true;
|
||
|
|
};
|
||
|
|
|
||
|
|
return {
|
||
|
|
async open({ targetKey, remotePort, webContents }) {
|
||
|
|
const port = Number.parseInt(String(remotePort), 10);
|
||
|
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error('A valid remote port is required');
|
||
|
|
if (!targetKey) throw new Error('A relay target key is required');
|
||
|
|
if (!webContents || webContents.isDestroyed?.()) throw new Error('The desktop window is unavailable');
|
||
|
|
|
||
|
|
const key = `${webContents.id}|${targetKey}|${port}`;
|
||
|
|
const existing = tunnels.get(key);
|
||
|
|
if (existing) return { localPort: existing.localPort, reused: true };
|
||
|
|
|
||
|
|
const connections = new Map();
|
||
|
|
const server = net.createServer((socket) => {
|
||
|
|
socket.setNoDelay(true);
|
||
|
|
socket.pause();
|
||
|
|
const connectionId = randomUUID();
|
||
|
|
const { port1, port2 } = createMessageChannel();
|
||
|
|
let closed = false;
|
||
|
|
const readyTimer = setTimeout(() => close(), CONNECTION_READY_TIMEOUT_MS);
|
||
|
|
|
||
|
|
const close = () => {
|
||
|
|
if (closed) return;
|
||
|
|
closed = true;
|
||
|
|
clearTimeout(readyTimer);
|
||
|
|
connections.delete(connectionId);
|
||
|
|
try { port1.postMessage({ type: 'close' }); } catch { /* already closed */ }
|
||
|
|
try { socket.destroy(); } catch { /* already closed */ }
|
||
|
|
try { port1.close(); } catch { /* already closed */ }
|
||
|
|
};
|
||
|
|
connections.set(connectionId, { close });
|
||
|
|
|
||
|
|
port1.on('message', (event) => {
|
||
|
|
const message = messageData(event);
|
||
|
|
if (!message) return;
|
||
|
|
if (message.type === 'ready') {
|
||
|
|
clearTimeout(readyTimer);
|
||
|
|
socket.resume();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (message.type === 'data' && message.data) {
|
||
|
|
socket.write(Buffer.from(message.data));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (message.type === 'close') close();
|
||
|
|
});
|
||
|
|
port1.on('close', close);
|
||
|
|
port1.start?.();
|
||
|
|
|
||
|
|
socket.on('data', (chunk) => {
|
||
|
|
if (closed) return;
|
||
|
|
port1.postMessage({ type: 'data', data: Uint8Array.from(chunk) });
|
||
|
|
});
|
||
|
|
socket.on('error', close);
|
||
|
|
socket.on('close', close);
|
||
|
|
|
||
|
|
try {
|
||
|
|
webContents.postMessage('openchamber:relay-dev-tunnel-connect', { connectionId, remotePort: port }, [port2]);
|
||
|
|
} catch (error) {
|
||
|
|
logger.warn?.(`[dev-tunnel] failed to hand relay connection to renderer: ${error?.message || error}`);
|
||
|
|
close();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const localPort = await listen(server);
|
||
|
|
server.on('error', (error) => logger.warn?.(`[dev-tunnel] relay listener failed: ${error?.message || error}`));
|
||
|
|
tunnels.set(key, { server, connections, localPort });
|
||
|
|
webContents.once?.('destroyed', () => closeTunnel(key));
|
||
|
|
return { localPort, reused: false };
|
||
|
|
},
|
||
|
|
|
||
|
|
closeAll() {
|
||
|
|
for (const key of [...tunnels.keys()]) closeTunnel(key);
|
||
|
|
},
|
||
|
|
|
||
|
|
closeForWebContents(webContentsId) {
|
||
|
|
let closed = 0;
|
||
|
|
const prefix = `${webContentsId}|`;
|
||
|
|
for (const key of [...tunnels.keys()]) {
|
||
|
|
if (!key.startsWith(prefix)) continue;
|
||
|
|
if (closeTunnel(key)) closed += 1;
|
||
|
|
}
|
||
|
|
return closed;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|