Logs.js

import { BASE_URL } from './Common.js'

export const TRACE = 10
export const DEBUG = 20
export const INFO = 30
export const WARN = 40
export const ERROR = 50
export const FATAL = 60

/**
 * @constant
 * @type {Object.<string, number>}
 *
 */
export const LOG_LEVELS = {
  trace: TRACE,
  debug: DEBUG,
  info: INFO,
  warn: WARN,
  error: ERROR,
  fatal: FATAL
}

/**
 * @classdesc Plugins write
 * {@link https://github.com/trentm/node-bunyan?tab=readme-ov-file#log-record-fields|Bunyan}
 * formated logs to separate log files. This class provides
 * utilities for viewing and following those logs as they're written.
 */
export class Logs {
  baseUrl
  #reader
  #controller
  /**
   * Instantiate a Log Object
   * @constructor
   * @param {string} [baseURL] - Protocol, domain, and port for the service.
   */
  constructor (baseUrl = BASE_URL) {
    this.baseUrl = baseUrl
  }

  /**
   * End the log stream and stop following logs.
   */
  end () {
    this.#reader?.cancel()
    this.#controller?.abort()
  }

  /**
   * Add a handler for following logs. Note that each invocation of this method
   * opens a connection to the server and holds it open. Web browsers limit the
   * number of connections to a single domain. Avoid using this method more than
   * once per client.
   * @param {string[]} plugins - Follow logs for the provided Plugin IDs.
   * @param {string|number} level - Log Level.
   * @param {function} callback - Invoked with two arguments. The first argument
   * is an Error object (if an error occurred) or null. The second argument is
   * an log object.
   */
  follow (plugins, level, callback) {
    let pluginList
    if (typeof plugins === 'object' && plugins.length > 0) {
      pluginList = plugins.join(',')
    } else {
      throw new Error('Invalid value provided for plugins argument')
    }

    const logLevel = (typeof level === 'number') ? level : LOG_LEVELS[level]

    if (!logLevel) {
      throw new Error('Invalid value provided for log level argument')
    }

    if (typeof callback !== 'function') {
      throw new Error('Invalid value provided for callback argument')
    }

    const url = `${this.baseUrl}/plugin/logs/follow?plugins=${pluginList}&level=${logLevel}`

    this.#controller = new AbortController()
    const signal = this.#controller.signal

    fetch(url, { signal })
      .then(({ body }) => {
        let buffer = ''
        if (!body) {
          return callback(new Error('No response body'))
        }
        const readData = data => {
          if (!data.done) {
            callback(null, data.value)
            this.#reader.read().then(readData).catch(e => callback(e))
          }
        }
        this.#reader = body
          .pipeThrough(new TextDecoderStream())
          .pipeThrough(new TransformStream({
            transform (chunk, controller) {
              buffer += chunk
              const parts = buffer.split('\n')
              parts.slice(0, -1).forEach(part => controller.enqueue(part))
              buffer = parts[parts.length - 1]
            },
            flush (controller) {
              if (buffer) {
                controller.enqueue(buffer)
              }
            }
          }))
          .pipeThrough(new TransformStream({
            transform (chunk, controller) {
              controller.enqueue(JSON.parse(chunk))
            }
          }))
          .getReader()
        this.#reader
          .read()
          .then(readData)
          .catch(e => callback(e))
      }).catch(e => {
        // Don't emit an error when aborting the fetch operation
        if (e.name !== 'AbortError') {
          callback(e)
        }
      })
  }
}