OverviewFilesCommitsIssuesDocsReleasesPeers

hyperident-shell / lib / shell.js

157 lines · javascript ref:
// Shell protocol — ported from hypershell (shell subset) to dual-runtime ESM.
// Top-level imports are all bare-safe (os/process via the #node: import map,
// compact-encoding + tt-native both load on bare), so this module loads on
// node AND bare. Local-terminal control (raw mode / window size) is only
// touched at client runtime and guarded, so importing is always safe.
import os from '#node:os'
import process from '#node:process'
import c from 'compact-encoding'
import PTY from 'tt-native'
import * as m from './messages.js'

const isWin = os.platform() === 'win32'
export const shellFile = isWin ? 'powershell.exe' : (process.env.SHELL || 'bash')
const EMPTY = Buffer.alloc(0)

// Server side: spawn a PTY and pipe it over a protomux channel. No local
// terminal needed here — fully clean on both node and bare.
export class ShellServer {
  constructor ({ mux }) {
    this.channel = mux.createChannel({
      protocol: 'hypershell',
      id: null,
      handshake: m.handshakeSpawn,
      onopen: this.onopen.bind(this),
      onclose: this.onclose.bind(this),
      messages: [
        { encoding: c.buffer, onmessage: this.onstdin.bind(this) },
        { encoding: c.buffer }, // stdout
        { encoding: c.buffer }, // stderr
        { encoding: c.uint }, // exit code
        { encoding: m.resize, onmessage: this.onresize.bind(this) }
      ]
    })
    this.pty = null
  }

  open () { this.channel.open({}) }

  onopen (handshake) {
    try {
      this.pty = PTY.spawn(handshake.command || shellFile, handshake.args, {
        cwd: os.homedir(),
        env: process.env,
        width: handshake.width,
        height: handshake.height
      })
    } catch (error) {
      this.channel.messages[3].send(1)
      this.channel.messages[2].send(Buffer.from(error.toString() + '\n'))
      this.channel.close()
      return
    }
    this.pty.on('data', (data) => this.channel.messages[1].send(data))
    this.pty.once('exit', (code) => this.channel.messages[3].send(code))
    this.pty.once('close', () => this.channel.close())
  }

  onclose () {
    if (this.pty) {
      try { this.pty.kill('SIGKILL') } catch {} // ignore "Process has exited"
    }
  }

  onstdin (data) {
    if (data === null) this.pty.write(EMPTY)
    else this.pty.write(data)
  }

  onresize (data) {
    if (this.pty) this.pty.resize(data.width, data.height)
  }
}

// Client side: pipe local stdio over the channel. Local-TTY control is guarded
// (works fully on node; degrades gracefully to cooked/fixed-size on bare until
// a bare-tty terminal layer is added — see TODO).
export class ShellClient {
  constructor (rawArgs, { socket, mux, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr }) {
    const spawn = ShellClient.parseVariadic(rawArgs)
    this.command = spawn.shift() || ''
    this.args = spawn
    this.socket = socket
    this.stdin = stdin
    this.stdout = stdout
    this.stderr = stderr

    this.channel = mux.createChannel({
      protocol: 'hypershell',
      id: null,
      handshake: m.handshakeSpawn,
      onopen: this.onopen.bind(this),
      onclose: this.onclose.bind(this),
      messages: [
        { encoding: c.buffer }, // stdin
        { encoding: c.buffer, onmessage: this.onstdout.bind(this) },
        { encoding: c.buffer, onmessage: this.onstderr.bind(this) },
        { encoding: c.uint, onmessage: this.onexitcode.bind(this) },
        { encoding: m.resize }
      ]
    })
  }

  size () {
    return {
      width: (this.stdout && this.stdout.columns) || 80,
      height: (this.stdout && this.stdout.rows) || 24
    }
  }

  open () {
    const { width, height } = this.size()
    this.channel.open({ command: this.command, args: this.args, width, height })
    this.setup()
  }

  onopen () {}
  onclose () { this.socket.end() }

  setup () {
    this.onstdin = this.onstdin.bind(this)
    this.onresize = this.onresize.bind(this)
    this.onsocketclose = this.onsocketclose.bind(this)

    // TODO(bare): on bare use bare-tty for raw mode + resize; guarded so the
    // client still works (cooked mode, fixed 80x24) where these are absent.
    if (this.stdin && this.stdin.isTTY && this.stdin.setRawMode) this.stdin.setRawMode(true)
    if (this.stdin && this.stdin.on) this.stdin.on('data', this.onstdin)
    if (this.stdout && this.stdout.on) this.stdout.on('resize', this.onresize)
    this.socket.on('close', this.onsocketclose)
    if (this.stdin && this.stdin.resume) this.stdin.resume()
  }

  onstdin (data) { this.channel.messages[0].send(data) }
  onstdout (data) { this.stdout.write(data) }
  onstderr (data) { this.stderr.write(data) }
  onexitcode (code) { process.exitCode = code }

  onresize () {
    const { width, height } = this.size()
    this.channel.messages[4].send({ width, height })
  }

  onsocketclose () {
    if (this.stdin && this.stdin.isTTY && this.stdin.setRawMode) this.stdin.setRawMode(false)
    if (this.stdin && this.stdin.removeListener) this.stdin.removeListener('data', this.onstdin)
    if (this.stdout && this.stdout.removeListener) this.stdout.removeListener('resize', this.onresize)
    this.socket.removeListener('close', this.onsocketclose)
    if (this.stdin && this.stdin.pause) this.stdin.pause()
  }

  static parseVariadic (rawArgs) {
    const index = rawArgs.indexOf('--')
    const variadic = index === -1 ? null : rawArgs.splice(index + 1)
    return variadic || []
  }
}