TR
apps/jumphp-ide-remote / ui / js / transport.js · commit b81e652
// Connection choice: DIRECT (same network, a TCP stream through the PHP side: Tcp = plain socket on the desktop,// the JumPHP runtime in a browser) raced against the RELAY (the page's own WebSocket, ciphertext only).// Direct gets a head start (1.5 s); the first transport that opens wins, the other is closed.import { Channel } from './channel.js';const b64 = (bytes) => { let s = ''; for (const b of bytes) s += String.fromCharCode(b); return btoa(s); };const unb64 = (t) => Uint8Array.from(atob(t), (c) => c.charCodeAt(0));const sleep = (ms) => new Promise((r) => setTimeout(r, ms));const never = new Promise(() => {});const handlers = new Map();let wired = false;function wire() {  if (wired || !window.jumphp) return;  wired = true;  window.jumphp.on('direct', (ev) => handlers.get(ev.id)?.(ev));}export function splitHostPort(hint) {  const m = /^\[([^\]]+)\]:(\d+)$/.exec(hint) || /^([^:]+):(\d+)$/.exec(hint);  return m ? { host: m[1], port: Number(m[2]) } : null;}/** One direct attempt: {ready: Promise<{transport, close}>, cancel()} */function directAttempt(hint) {  const target = splitHostPort(hint);  const id = 'd' + Math.random().toString(36).slice(2, 10);  let dataCb = null; let closeCb = null; const pending = [];  const transport = Channel.streamTransport({    send: (bytes) => window.jumphp.invoke('direct.write', { id, data: b64(bytes) }),    onData: (cb) => { dataCb = cb; for (const c of pending.splice(0)) cb(c); },    onClose: (cb) => { closeCb = cb; },    close: () => window.jumphp.invoke('direct.close', { id }),  });  const cancel = () => { handlers.delete(id); window.jumphp.invoke('direct.close', { id }).catch(() => {}); };  const ready = new Promise((resolve, reject) => {    if (!target) return reject(new Error('bad hint'));    handlers.set(id, (ev) => {      if (ev.type === 'open') resolve({ transport, cancel });      else if (ev.type === 'data') { const d = unb64(ev.data); dataCb ? dataCb(d) : pending.push(d); }      else if (ev.type === 'error') reject(new Error(ev.message || 'direct failed'));      else if (ev.type === 'close') { reject(new Error('closed')); closeCb?.(ev.message); }    });    window.jumphp.invoke('direct.open', { id, ...target }).then((r) => { if (r && r.ok === false) reject(new Error(r.error)); }, reject);  });  ready.catch(() => handlers.delete(id));  return { ready, cancel };}/** Direct is worth trying on the desktop, or in a browser when the JumPHP runtime is ready (net_mode auto). */export async function directPossible(settings, hints) {  if (!hints.length || !window.jumphp) return false;  wire();  if (settings.native) return true;  if (settings.net_mode === 'server') return false;  try { return (await window.jumphp.invoke('runtime.state', {})).state === 'ready'; } catch { return false; }}function openChannel(key, transport) {  return Channel.open({ role: 'remote', key, transport });}function waitOpen(ch, ms) {  return new Promise((resolve, reject) => {    if (ch.isOpen) return resolve(ch);    const t = setTimeout(() => reject(new Error('timeout')), ms);    ch.on('open', () => { clearTimeout(t); resolve(ch); });    ch.on('close', () => { clearTimeout(t); reject(new Error('closed')); });  });}/** @returns {Promise<{channel, via:'direct'|'relay'}>} */export async function connect({ pairing, settings, headStart = (settings.direct_head_start || 1.5) * 1000, relayTimeout = 20000 }) {  const attempts = (await directPossible(settings, pairing.direct)) ? pairing.direct.slice(0, 3).map(directAttempt) : [];  const direct = attempts.length ? Promise.any(attempts.map((a) => a.ready)) : null;  const dropAttempts = (keep) => attempts.forEach((a) => { if (a.cancel !== keep) a.cancel(); });  const viaDirect = async (won) => {    dropAttempts(won.cancel);    const channel = await openChannel(pairing.key, won.transport);    return { channel, via: 'direct' };  };  if (direct) {    const first = await Promise.race([direct.catch(() => null), sleep(headStart).then(() => 'late')]);    if (first && first !== 'late') return viaDirect(first);  }  const relayUrl = settings.relay_url;  const relayChannel = await openChannel(pairing.key, Channel.relayTransport({ url: relayUrl, room: pairing.room, role: 'remote' }));  const relay = waitOpen(relayChannel, relayTimeout).then(() => ({ channel: relayChannel, via: 'relay' }));  const late = direct ? direct.then(viaDirect, () => never) : never;  const winner = await Promise.race([relay, late]);  if (winner.via === 'direct') relayChannel.close(); else dropAttempts(null);  return winner;}