senecajs/seneca-repl

View on GitHub
src/repl.ts

Summary

Maintainability
A
0 mins
Test Coverage
/* Copyright © 2015-2023 Richard Rodger and other contributors, MIT License. */

// TODO: make listener start flag controlled, useful tests

// NOTE: vorpal is not used server-side to keep things lean

import { PassThrough } from 'node:stream'

import Net, { Server } from 'node:net'
import Repl from 'node:repl'
import Vm from 'node:vm'
import Util from 'node:util'

import { Open } from 'gubu'
import Hoek from '@hapi/hoek'

const Inks = require('inks')

import type { Cmd } from './types'

import { Cmds } from './cmds'

import { makeInspect } from './utils'

const intern = (repl.intern = make_intern())

const default_cmds: any = {}

for (let cmd of Object.values(Cmds)) {
  default_cmds[cmd.name.toLowerCase().replace(/cmd$/, '')] = cmd
}

function repl(this: any, options: any) {
  let seneca = this
  // let mark = Math.random()

  let server: any = null
  let export_address: Record<string, any> = {}
  let replMap: Record<string, any> = {}
  let cmdMap: Record<string, any> = Object.assign(
    {},
    default_cmds,
    options.cmds,
  )

  seneca.add('sys:repl,use:repl', use_repl)
  seneca.add('sys:repl,send:cmd', send_cmd)

  seneca.add('sys:repl,add:cmd', add_cmd)
  seneca.add('sys:repl,echo:true', (msg: any, reply: any) => reply(msg))

  seneca.message('role:seneca,cmd:close', cmd_close)

  seneca.prepare(async function () {
    if (options.listen) {
      server = Net.createServer(function (socket) {
        socket.on('error', function (err) {
          seneca.log.error('repl-socket', err)
        })

        // TODO: fix: should be socket address!!!
        let address: any = server.address()

        seneca.act('sys:repl,use:repl', {
          id: address.address + '~' + address.port,
          server,
          input: socket,
          output: socket,
        })
      })

      server.listen(options.port, options.host)

      let pres = new Promise<void>((resolve, reject) => {
        server.on('error', function (err: any) {
          seneca.log.error('repl-server', err)
          reject(err)
        })

        server.on('listening', function () {
          let address: any = server.address()

          export_address.port = address.port
          export_address.host = address.address
          export_address.family = address.family

          seneca.log.info({
            kind: 'notice',
            notice: 'REPL listening on ' + address.address + ':' + address.port,
          })

          resolve()
        })
      })

      return pres
    }
  })

  async function cmd_close(this: any, msg: any) {
    const seneca = this

    if (options.listen && server) {
      server.close((err: any) => {
        if (err) {
          seneca.log.error('repl-close-server', err)
        }
      })
    }

    for (let replInst of Object.values(replMap)) {
      await replInst.destroy()
    }

    return seneca.prior(msg)
  }

  function use_repl(this: any, msg: any, reply: any) {
    let seneca = this
    let replID = msg.id || options.host + '~' + options.port

    let replInst: ReplInstance = replMap[replID]

    if (replInst && 'open' === replInst.status) {
      return reply({
        ok: true,
        repl: replInst,
      })
    }

    let server = msg.server
    let input = msg.input || new PassThrough()
    let output = msg.output || new PassThrough()

    let replSeneca = seneca.root.delegate({ repl$: true, fatal$: false })
    replSeneca.did = replSeneca.did + '~repl$'

    replMap[replID] = replInst = new ReplInstance({
      id: replID,
      options,
      cmdMap,
      input,
      output,
      server,
      seneca: replSeneca,
      event: (name: string) => {
        if ('exit' === name) {
          setTimeout(() => {
            delete replMap[replID]
          }, 1111)
        }
      },
    })

    replInst.update('open')

    return reply({
      ok: true,
      repl: replInst,
    })
  }

  // TODO: fix: append newline id needed, otherwise times out!
  function send_cmd(this: any, msg: any, reply: any) {
    let seneca = this

    // lookup repl by id, using steams to submit cmd and send back response

    let replID = msg.id || options.host + ':' + options.port
    let replInst = replMap[replID]

    if (null == replInst) {
      return seneca.fail('unknown-repl', { id: replID })
    } else if ('open' !== replInst.status) {
      return seneca.fail('invalid-status', {
        id: replID,
        status: replInst.status,
      })
    }

    let cmd = msg.cmd
    if (!cmd.endsWith('\n')) {
      cmd += '\n'
    }

    let out: any = []

    // TODO: dedup this
    // use a FILO queue

    let listener = (chunk: Buffer) => {
      if (0 === chunk[0]) {
        replInst.output.removeListener('data', listener)
        reply({ ok: true, out: out.join('') })
      }

      out.push(chunk.toString())
    }

    replInst.output.on('data', listener)

    replInst.input.write(cmd)
  }

  function add_cmd(this: any, msg: any, reply: any) {
    let name = msg.name
    let action = msg.action

    if ('string' === typeof name && 'function' === typeof action) {
      cmdMap[name] = action
    } else {
      this.fail('invalid-cmd')
    }

    reply()
  }
  add_cmd.desc = 'Add a REPL command dynamically'

  return {
    name: 'repl',
    exportmap: {
      address: export_address,
    },
  }
}

/*
function updateStatus(replInst: any, newStatus: string) {
  replInst.status = newStatus
  replInst.log.push({
    kind: 'status',
    status: newStatus,
    when: Date.now()
  })
}
*/

function make_intern() {
  return {
    fmt_index: function (i: any) {
      return ('' + i).substring(1)
    },

    make_log_handler: function (context: any) {
      return function log_handler(data: any) {
        if (context.log_capture) {
          let seneca = context.seneca
          let out = seneca.__build_test_log__$$
            ? seneca.__build_test_log__$$(seneca, 'test', data)
            : context.inspekt(data).replace(/\n/g, ' ')

          if (
            null == context.log_match ||
            -1 < out.indexOf(context.log_match)
          ) {
            context.socket.write('LOG: ' + out)
          }
        }
      }
    },

    make_on_act_in: function (context: any) {
      return function on_act_in(actdef: any, args: any, meta: any) {
        if (!context.act_trace) return

        let actid = (meta || args.meta$ || {}).id
        context.socket.write(
          'IN  ' +
            intern.fmt_index(context.act_index) +
            ': ' +
            context.inspekt(context.seneca.util.clean(args)) +
            ' # ' +
            actid +
            ' ' +
            actdef.pattern +
            ' ' +
            actdef.id +
            ' ' +
            actdef.action +
            ' ' +
            (actdef.callpoint ? actdef.callpoint : '') +
            '\n',
        )
        context.act_index_map[actid] = context.act_index
        context.act_index++
      }
    },

    make_on_act_out: function (context: any) {
      return function on_act_out(_actdef: any, out: any, meta: any) {
        if (!context.act_trace) return

        let actid = (meta || out.meta$ || {}).id

        out =
          out && out.entity$
            ? out
            : context.inspekt(context.seneca.util.clean(out))

        let cur_index = context.act_index_map[actid]
        context.socket.write(
          'OUT ' + intern.fmt_index(cur_index) + ': ' + out + '\n',
        )
      }
    },

    make_on_act_err: function (context: any) {
      return function on_act_err(_actdef: any, err: any, meta: any) {
        if (!context.act_trace) return

        let actid = (meta || err.meta$ || {}).id

        if (actid) {
          let cur_index = context.act_index_map[actid]
          context.socket.write(
            'ERR ' + intern.fmt_index(cur_index) + ': ' + err.message + '\n',
          )
        }
      }
    },
  }
}

repl.defaults = {
  listen: true,
  port: 30303,
  host: '127.0.0.1',
  depth: 11,
  alias: Open({
    stats: 'seneca.stats()',
    'stats full': 'seneca.stats({summary:false})',

    // DEPRECATED
    'stats/full': 'seneca.stats({summary:false})',

    // TODO: there should be a seneca.tree()
    // tree: 'seneca.root.private$.actrouter',
  }),
  inspect: Open({}),
  cmds: Open({
    // custom cmds
  }),
}

repl.Cmds = Cmds

class ReplInstance {
  id: string
  repl: any
  status: string = 'init'
  log: any[] = []
  input: any
  output: any
  server: Server | undefined
  seneca: any
  options: any
  cmdMap: any
  event: any
  state = {
    data: false,
  }

  constructor(spec: any) {
    this.id = spec.id
    this.cmdMap = spec.cmdMap
    this.server = spec.server
    this.event = spec.event

    const options = (this.options = spec.options)
    const input = (this.input = spec.input)
    const output = (this.output = spec.output)
    const seneca = (this.seneca = spec.seneca)

    const repl = (this.repl = Repl.start({
      // prompt: 'seneca ' + seneca.version + ' ' + seneca.id + '> ',
      prompt: '',
      input,
      output,
      terminal: false,
      useGlobal: false,
      eval: this.evaluate.bind(this),
      writer: this.writer.bind(this),
    }))

    repl.on('exit', () => {
      this.update('closed')
      input.end()
      output.end()
      this.event('exit')
    })

    repl.on('error', (err: any) => {
      seneca.log.error('repl', err)
      this.event('error')
    })

    Object.assign(repl.context, {
      // NOTE: don't trigger funnies with a .inspect property
      inspekt: makeInspect(repl.context, {
        ...options.inspect,
        depth: options.depth,
      }),
      input,
      output,
      s: seneca,
      seneca,
      plain: false,
      history: [],
      log_capture: false,
      log_match: null,
      alias: options.alias,
      act_trace: false,
      act_index_map: {},
      act_index: 1000000,
      cmdMap: this.cmdMap,
      delegate: {
        repl$: seneca,
        root$: seneca.root,
      },
    })

    seneca.on_act_in = intern.make_on_act_in(repl.context)
    seneca.on_act_out = intern.make_on_act_out(repl.context)
    seneca.on_act_err = intern.make_on_act_err(repl.context)

    seneca.on('log', intern.make_log_handler(repl.context))
  }

  update(status: string) {
    this.status = status
  }

  writer(this: any, val: any) {
    if (this.state.data) {
      this.state.data = false
      return Util.inspect(val, {
        depth: null,
        maxArrayLength: null,
        maxStringLength: null,
        breakLength: Infinity,
        compact: true,
      })
    } else {
      return Util.inspect(val)
    }
  }

  evaluate(cmdtext: any, context: any, filename: any, origRespond: any) {
    const seneca = this.seneca
    const repl = this.repl
    const options = this.options
    const alias = options.alias
    const output = this.output

    const respond = (err: any, res?: any, opts: any = {}) => {
      if (true === opts.data) {
        this.state.data = true
      }

      origRespond(err, res)
      output.write(String.fromCharCode(0))
      // output.write(new Uint8Array([0]))
      // output.write('Z')
    }

    try {
      let cmd_history = context.history

      cmdtext = cmdtext.trim()

      if ('last' === cmdtext && 0 < cmd_history.length) {
        cmdtext = cmd_history[cmd_history.length - 1]
      } else {
        cmd_history.push(cmdtext)
      }

      if (alias[cmdtext]) {
        cmdtext = alias[cmdtext]
      }

      let m = cmdtext.match(/^(\S+)/)
      let cmd = m && m[1]

      let argstr = 'string' === typeof cmd ? cmdtext.substring(cmd.length) : ''

      // NOTE: alias can also apply just to command
      if (alias[cmd]) {
        cmd = alias[cmd]
      }

      let cmd_func: Cmd = this.cmdMap[cmd]

      if (cmd_func) {
        return cmd_func({ name: cmd, argstr, context, options, respond })
      }

      if (!execute_action(cmdtext)) {
        // context.s.ready(() => {
        execute_script(cmdtext)
        //})
      }

      function execute_action(cmdtext: string) {
        try {
          let msg = cmdtext

          let m = msg.split(/\s*~>\s*/)
          if (2 === m.length) {
            msg = m[0]
          }

          let injected_msg = Inks(msg, context)
          let args = seneca.util.Jsonic(injected_msg)

          let notmsg =
            null == args || Array.isArray(args) || 'object' !== typeof args

          if (notmsg) {
            return false
          }

          context.s.act(args, function (err: any, out: any) {
            context.err = err
            context.out = out

            // EXPERIMENTAL! msg ~> x saves msg result into x
            if (m[1]) {
              let ma = m[1].split(/\s*=\s*/)
              if (2 === ma.length) {
                context[ma[0]] = Hoek.reach({ out: out, err: err }, ma[1])
              } else {
                context[m[1]] = out
              }
            }

            if (out && !repl.context.act_trace) {
              // out =
              //   out && out.entity$
              //     ? out
              //     : context.inspekt(seneca.util.clean(out))

              respond(null, out)
              // output.write(out + '\n')
              // output.write(new Uint8Array([0]))
            } else if (err) {
              // output.write(context.inspekt(err) + '\n')
              respond(err)
            }
          })

          return true
        } catch (e) {
          // Not jsonic format, so try to execute as a script
          // TODO: check actual jsonic parse error so we can give better error
          // message if not
          return false
        }
      }

      function execute_script(cmdtext: any) {
        try {
          let script = (Vm as any).createScript(cmdtext, {
            filename: filename,
            displayErrors: false,
          })

          let result = script.runInContext(context, {
            displayErrors: false,
          })

          result = result === seneca ? null : result
          return respond(null, result)
        } catch (e: any) {
          if ('SyntaxError' === e.name && e.message.startsWith('await')) {
            let wrapper = '(async () => { return (' + cmdtext + ') })()'

            try {
              let script = (Vm as any).createScript(wrapper, {
                filename: filename,
                displayErrors: false,
              })

              let out = script.runInContext(context, {
                displayErrors: false,
              })

              out
                .then((result: any) => {
                  result = result === seneca ? null : result
                  respond(null, result)
                })
                .catch((e: any) => {
                  return respond(e)
                })
            } catch (e: any) {
              return respond(e)
            }
          } else {
            // return respond(e.message)
            return respond(e)
          }
        }
      }
    } catch (e) {
      return respond(e)
    }
  }

  async destroy(this: any) {
    const seneca = this.seneca

    try {
      this.input?.destroy && this.input.destroy()
    } catch (err) {
      seneca.log.error('repl-close-input', err, { id: this.id })
    }

    try {
      this.output?.destroy && this.output.destroy()
    } catch (err) {
      seneca.log.error('repl-close-output', err, { id: this.id })
    }

    if (this.server?.close && this.server.listening) {
      return new Promise<void>((resolve) => {
        this.server.close((err: any) => {
          if (err) {
            seneca.log.error('repl-close-server', err, { id: this.id })
          }
          resolve()
        })
      })
    }
  }
}

module.exports = repl