fix: support dev server previews over relay
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user