// Shell client: connect to a server pubkey with the device keyPair and drive a
// ShellClient over protomux. Retries transient pre-establishment failures (DHT
// hole-punching can abort on the first try — HOLEPUNCH_ABORTED — then succeed).
import DHT from 'hyperdht'
import Protomux from 'protomux'
import process from '#node:process'
import { ShellClient } from './shell.js'
// Codes worth retrying before the encrypted connection is established.
const TRANSIENT = new Set(['HOLEPUNCH_ABORTED', 'ETIMEDOUT', 'PEER_NOT_FOUND', 'ECONNRESET'])
export function connect ({ serverPublicKey, keyPair, command = [], retries = 5, onerror = null, stdio = null } = {}) {
const state = { node: null, socket: null, shell: null }
let attempt = 0
function fail (err) {
if (onerror) onerror(err)
else process.stderr.write('connection failed: ' + (err.code || err.message) + '\n')
process.exitCode = 1
}
function attemptConnect () {
attempt++
const node = new DHT()
const socket = node.connect(serverPublicKey, { keyPair })
state.node = node
state.socket = socket
let established = false
socket.setKeepAlive(5000)
// 'open' fires once the noise handshake completes (connection established).
// Only then wire up the shell — so a retry never double-attaches stdin.
socket.once('open', () => {
established = true
const mux = new Protomux(socket)
state.shell = new ShellClient(command, { socket, mux, ...(stdio || {}) })
state.shell.open()
socket.once('close', () => node.destroy())
})
socket.on('error', (err) => {
if (established) { fail(err); return }
// Pre-establishment failure — retry (transient/hole-punch races) or give up.
try { socket.destroy() } catch {}
node.destroy().finally(() => {
if (attempt <= retries && (TRANSIENT.has(err.code) || !err.code)) {
if (attempt === 1) process.stderr.write('connecting…\n')
setTimeout(attemptConnect, 400 * attempt) // linear backoff
} else {
fail(err)
}
})
})
}
attemptConnect()
return state
}