index.js

import collectRoutePathHandlers from './lib/collect-route-path-handlers.js'
import fastify from 'fastify'
import appHook from './lib/app-hook.js'
import routeHook from './lib/webapp-scope/route-hook.js'
import printRoutes from './lib/print-routes.js'
import { boot } from './lib/app.js'
import sensible from '@fastify/sensible'
import noIcon from 'fastify-no-icon'
import underPressure from '@fastify/under-pressure'
import handleForward from './lib/handle-forward.js'
import handleRedirect from './lib/handle-redirect.js'
import buildLocals from './lib/build-locals.js'
import queryString from 'query-string'

/**
 * @typedef TEscapeChars
 * @type {Object}
 * @memberof Waibu
 * @property {string} <=<
 * @property {string} >=>
 * @property {string} "="
 * @property {string} '='
 */

/**
 * Plugin factory
 *
 * @param {string} pkgName - NPM package name
 * @returns {class}
 */
async function factory (pkgName) {
  const me = this

  /**
   * Waibu Web Framework plugin for Bajo. This is the main foundation of all web apps attached to
   * the system through a route prefix. Those web apps are then build as childrens with
   * its own fastify's context.
   *
   * There are currently 3 web apps available:
   * - {@link https://github.com/ardhi/waibu-static|waibu-static} for static content delivery
   * - {@link https://github.com/ardhi/waibu-rest-api|waibu-rest-api} for rest api setup
   * - and {@link https://github.com/ardhi/waibu-mpa|waibu-mpa} for normal multi-page application
   *
   * You should write your code as the extension of above web apps. Not to this main app, unless
   * you want to use write custom web apps with its own context.
   *
   * @class
   */
  class Waibu extends this.lib.Plugin {
    /**
     * @constant {string[]}
     * @default ['onRequest', 'onResponse', 'preParsing', 'preValidation', 'preHandler', 'preSerialization', 'onSend', 'onTimeout', 'onError']
     * @memberof Waibu
     */
    static hookTypes = ['onRequest', 'onResponse', 'preParsing', 'preValidation', 'preHandler',
      'preSerialization', 'onSend', 'onTimeout', 'onError']

    /**
     * @constant {string}
     * @memberof Waibu
     * @default 'w'
     */
    static alias = 'w'

    /**
     * @constant {string[]}
     * @default ['bajo-extra']
     * @memberof Waibu
     */
    static dependencies = ['bajo-extra']

    /**
     * @constant {TEscapeChars}
     * @memberof Waibu
     */
    static escapeChars = {
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&apos;'
    }

    constructor () {
      super(pkgName, me.app)

      /**
       * @see {@tutorial config}
       * @type {Object}
       */
      this.config = {
        server: {
          host: '127.0.0.1',
          port: 7771
        },
        factory: {
          trustProxy: true,
          bodyLimit: 10485760,
          pluginTimeout: 30000
        },
        deferLog: false,
        prefixVirtual: '~',
        qsKey: {
          bbox: 'bbox',
          bboxLatField: 'bboxLatField',
          bboxLngField: 'bboxLngField',
          query: 'query',
          match: 'match',
          skip: 'skip',
          page: 'page',
          limit: 'limit',
          sort: 'sort',
          fields: 'fields',
          lang: 'lang'
        },
        paramsCharMap: {},
        printRoutes: true,
        pageTitleFormat: '%s : %s',
        siteInfo: {
          title: 'My Website',
          orgName: 'My Organization'
        },
        cors: {},
        compress: {},
        helmet: {},
        rateLimit: {},
        multipart: {
          attachFieldsToBody: true,
          limits: {
            parts: 100,
            fileSize: 10485760
          }
        },
        noIcon: true,
        underPressure: false,
        forwardOpts: {
          disableRequestLogging: true,
          undici: {
            connections: 128,
            pipelining: 1,
            keepAliveTimeout: 60 * 1000,
            tls: {
              rejectUnauthorized: false
            }
          }
        }
      }
      this.qs = {
        parse: (item) => {
          return queryString.parse(item, {
            parseBooleans: true,
            parseNumbers: true
          })
        },
        parseUrl: queryString.parseUrl,
        stringify: queryString.stringify,
        stringifyUrl: queryString.stringifyUrl
      }
    }

    /**
     * Initialize plugin
     *
     * @method
     * @async
     */
    init = async () => {
      if (this.config.home === '/') this.config.home = false
      await collectRoutePathHandlers.call(this)
    }

    /**
     * Start plugin
     *
     * @method
     * @async
     */
    start = async () => {
      const { generateId, runHook } = this.app.bajo
      const cfg = this.getConfig()
      if (this.app.bajoLogger) {
        cfg.factory.loggerInstance = this.app.bajoLogger.instance.child(
          {},
          { msgPrefix: '[waibu] ' }
        )
      }
      cfg.factory.genReqId = req => generateId()
      cfg.factory.disableRequestLogging = true
      cfg.factory.querystringParser = str => this.qs.parse(str)

      const instance = fastify(cfg.factory)
      instance.decorateRequest('lang', null)
      instance.decorateRequest('t', () => {})
      instance.decorateRequest('format', () => {})
      instance.decorateRequest('langDetector', null)
      instance.decorateRequest('site', null)
      instance.decorateRequest('ns', null)
      this.instance = instance
      this.routes = this.routes || []
      await runHook('waibu:afterCreateContext', instance)
      await instance.register(sensible)
      if (cfg.underPressure) await instance.register(underPressure)
      if (cfg.noIcon) await instance.register(noIcon)
      await handleRedirect.call(this, instance)
      await handleForward.call(this, instance)
      await appHook.call(this)
      await routeHook.call(this, this.name)
      await boot.call(this)
      await instance.listen(cfg.server)
      if (cfg.printRoutes) printRoutes.call(this)
    }

    /**
     * Exit handler
     *
     * @method
     * @async
     */
    exit = async () => {
      this.instance.close()
    }

    /**
     * Find route by route name
     *
     * @param {string} name - ns based route name
     * @returns {Object} Route object
     */
    findRoute = (name) => {
      const { outmatch } = this.lib
      const { find } = this.lib._
      const { breakNsPath } = this.app.bajo
      let { ns, subNs = '', path } = breakNsPath(name)
      const params = path.split('|')
      if (params.length > 1) path = params[0]
      return find(this.routes, r => {
        if (r.path.startsWith('*')) return false
        r.config = r.config ?? {}
        const match = outmatch(r.config.pathSrc ?? r.path, { separator: false })
        if (!match(path)) return false
        return ns === r.config.ns && r.config.subNs === subNs
      })
    }

    get escapeChars () {
      return this.constructor.escapeChars
    }

    /**
     * Escape text
     *
     * @method
     * @param {string} text
     * @returns {string}
     */
    escape = (text = '') => {
      if (typeof text !== 'string') return text
      const { forOwn } = this.lib._
      forOwn(this.escapeChars, (v, k) => {
        text = text.replaceAll(k, v)
      })
      return text
    }

    /**
     * Fetch something from url. A wrapper of bajo-extra's fetchUrl which support
     * bajo's ns based url.
     *
     * @method
     * @async
     * @param {string} url - Also support ns based url
     * @param {Object} [opts={}] - node's fetch options
     * @param {Object} [extra={}] - See {@link https://ardhi.github.io/bajo-extra|bajo-extra}
     * @returns {Object}
     */
    fetch = async (url, opts = {}, extra = {}) => {
      const { fetch } = this.app.bajoExtra
      extra.rawResponse = true

      url = this.routePath(url, { guessHost: true })
      const resp = await fetch(url, opts, extra)
      const result = await resp.json()
      if (!resp.ok) {
        throw this.error(result.message, {
          statusCode: resp.status,
          success: false
        })
      }
      return result
    }

    /**
     * Get visitor IP from fastify's request object
     *
     * @method
     * @param {Object} req - request object
     * @returns {string}
     */
    getIp = (req) => {
      const { isEmpty } = this.lib._
      let fwd = req.headers['x-forwarded-for'] ?? ''
      if (!Array.isArray(fwd)) fwd = fwd.split(',').map(ip => ip.trim())
      return isEmpty(fwd[0]) ? req.ip : fwd[0]
    }

    /**
     * Get origin of fastify's request object
     *
     * @method
     * @param {Object} req
     * @returns {string}
     */
    getOrigin = (req) => {
      const { isEmpty } = this.lib._
      let host = req.host
      if (isEmpty(host) || host === ':authority') host = `${this.config.server.host}:${this.config.server.port}`
      return `${req.protocol}://${host}`
    }

    /**
     * Get plugin by prefix
     *
     * @method
     * @param {string} prefix
     * @returns {Object}
     */
    getPluginByPrefix = (prefix) => {
      const { get, find } = this.lib._
      const item = find(this.app.waibu.routes, r => {
        return get(r, 'config.prefix') === prefix
      })
      const ns = get(item, 'config.ns')
      if (ns) return this.app[ns]
    }

    /**
     * Get plugin's prefix by name
     *
     * @method
     * @param {string} name - Plugin's name
     * @param {string} [webApp=waibuMpa] - Web app to use
     * @returns {string}
     */
    getPluginPrefix = (name, webApp = 'waibuMpa') => {
      const { get, trim } = this.lib._
      let prefix = get(this, `app.${name}.config.waibu.prefix`, this.app[name].alias)
      if (name === 'main') {
        const cfg = this.app[webApp].config
        if (cfg.mountMainAsRoot) prefix = ''
      }

      return trim(prefix, '/')
    }

    /**
     * Get all available routes
     *
     * @method
     * @param {boolean} [grouped=false] - Returns as groups of urls and methods
     * @param {*} [lite=false] - Retuns only urls and methods
     * @returns {Array}
     */
    getRoutes = (grouped = false, lite = false) => {
      const { groupBy, orderBy, mapValues, map, pick } = this.lib._
      const all = this.routes
      let routes
      if (grouped) {
        const group = groupBy(orderBy(all, ['url', 'method']), 'url')
        routes = lite ? mapValues(group, (v, k) => map(v, 'method')) : group
      } else if (lite) routes = map(all, a => pick(a, ['url', 'method']))
      else routes = all
      return routes
    }

    /**
     * Get uploaded files by request ID
     *
     * @method
     * @param {string} reqId - Request ID
     * @param {boolean} [fileUrl=false] - If ```true```, files returned as file url format (```file:///...```)
     * @param {*} returnDir - If ```true```, also return its directory
     * @returns {(Object|Array)} - Returns object if ```returnDir``` is ```true```, array of files otherwise
     */
    getUploadedFiles = async (reqId, fileUrl = false, returnDir = false) => {
      const { getPluginDataDir, resolvePath } = this.app.bajo
      const { fastGlob } = this.lib
      const dir = `${getPluginDataDir(this.name)}/upload/${reqId}`
      const result = await fastGlob(`${dir}/*`)
      if (!fileUrl) return returnDir ? { dir, files: result } : result
      const files = result.map(f => resolvePath(f, true))
      return returnDir ? { dir, files } : files
    }

    /**
     * Is namespace's path contains language detector token?
     *
     * @method
     * @param {string} ns - Plugin name
     * @returns {boolean}
     */
    isIntlPath = (ns) => {
      const { get } = this.lib._
      return get(this.app[ns], 'config.intl.detectors', []).includes('path')
    }

    notFound = (name, options) => {
      throw this.error('_notFound', { path: name })
    }

    /**
     * Parse filter found from Fastify's request based on keys set in config object
     *
     * @method
     * @param {Object} req - Request object
     * @returns {Object}
     */
    parseFilter = (req) => {
      const result = {}
      const items = Object.keys(this.config.qsKey)
      for (const item of items) {
        result[item] = req.query[this.config.qsKey[item]]
      }
      return result
    }

    /**
     * Get route directory by plugin's name
     *
     * @param {*} ns - Namespace
     * @param {*} [baseNs] - Base namespace. If not provided, defaults to scope's ns
     * @returns {string}
     */
    routeDir = (ns, baseNs) => {
      const { get } = this.lib._
      if (!baseNs) baseNs = ns
      const cfg = this.app[baseNs].config
      const prefix = get(cfg, 'waibu.prefix', this.app[baseNs].alias)
      const dir = prefix === '' ? '' : `/${prefix}`
      const cfgMpa = get(this, 'app.waibuMpa.config')
      if (ns === this.app.bajo.mainNs && cfgMpa.mountMainAsRoot) return ''
      if (ns === baseNs) return dir
      return dir + `/${get(this.app[ns].config, 'waibu.prefix', this.app[ns].alias)}`
    }

    /**
     * Get route path by route's name:
     * - If it is a ```mailto:``` or ```tel:``` url, it returns as is
     * - If it is a ns based name, it will be parsed first
     *
     * @method
     * @param {string} name
     * @param {Object} [options={}] - Options object
     * @param {string} [options.base=waibu] - Base namespace
     * @param {boolean} [options.guessHost] - If true, guest host if host is not set
     * @param {Object} [options.query={}] - Query string's object. If provided, it will be added to returned value
     * @param {Object} [options.params={}] - Parameter object. If provided, it will be merged to returned value
     * @returns {string}
     */
    routePath = (name, options = {}) => {
      const { getPlugin } = this.app.bajo
      const { defaultsDeep } = this.lib.aneka
      const { isEmpty, get, trimEnd, trimStart } = this.lib._
      const { breakNsPath } = this.app.bajo
      const { query = {}, base = this.name, params = {}, guessHost } = options

      const plugin = getPlugin(base)
      const cfg = plugin.config ?? {}
      let info = {}
      if (name.startsWith('mailto:') || name.startsWith('tel:')) return name
      if (['%', '.', '/', '?', '#'].includes(name[0]) || name.slice(1, 2) === ':') info.path = name
      else if (['~'].includes(name[0])) info.path = name.slice(1)
      else {
        info = breakNsPath(name)
      }
      if (info.path.slice(0, 2) === './') info.path = info.path.slice(2)
      if (this.routePathHandlers[info.subNs]) return this.routePathHandlers[info.subNs].handler(name, options)
      if (info.path.includes('//')) return info.path

      info.path = info.path.split('/').map(p => {
        return p[0] === ':' && params[p.slice(1)] ? params[p.slice(1)] : p
      }).join('/')
      let url = info.path
      const langDetector = get(cfg, 'intl.detectors', [])
      if (info.ns) url = trimEnd(langDetector.includes('path') ? `/${params.lang ?? ''}${this.routeDir(info.ns)}${info.path}` : `${this.routeDir(info.ns)}${info.path}`, '/')
      if (options.uriEncoded) url = url.split('/').map(u => encodeURI(u)).join('/')
      info.qs = defaultsDeep({}, query, info.qs)
      if (!isEmpty(info.qs)) url += '?' + this.qs.stringify(info.qs)
      if (!url.startsWith('http') && guessHost) url = `http://${this.config.server.host}:${this.config.server.port}/${trimStart(url, '/')}`
      return url
    }

    /**
     * Method to send mail through Masohi Messaging System. It is a thin wrapper
     * for {@link https://github.com/ardhi/masohi-mail|masohi-mail} send method.
     *
     * If masohi is not loaded, nothing is delivered.
     *
     * @method
     * @async
     * @param {(string|Array)} tpl - Mail's template to use. If a string is given, the same template will be used for html & plaintext versions. Otherwise, the first template will be used for html mail, and the second one is for it's plaintext version
     * @param {Object} [params={}] - {@link https://github.com/ardhi/masohi-mail|masohi-mail}'s params object.
     * @returns
     */
    sendMail = async (tpl, { to, cc, bcc, from, subject, data = {}, conn, source, options = {} }) => {
      conn = conn ?? 'masohiMail:default'
      if (!this.app.masohi || !this.app.masohiMail) return
      const { get, isString } = this.lib._
      const { generateId } = this.app.bajo
      const { render } = this.app.bajoTemplate
      if (isString(tpl)) tpl = [tpl]
      const locals = await buildLocals.call(this, { tpl, params: data, opts: options })
      const opts = {
        lang: get(options, 'req.lang'),
        groupId: get(options, 'req.id', generateId())
      }
      const message = await render(tpl[0], locals, opts)
      if (tpl[1]) opts.messageText = await render(tpl[1], locals, opts)
      const payload = { type: 'object', data: { to, cc, bcc, from, subject, message, options: opts } }
      await this.app.masohi.send({ payload, source: source ?? this.name, conn }) // mail sent through worker
    }

    /**
     * Recursively unescape block of texts
     *
     * @method
     * @param {string} content - Source content
     * @param {string} start - Block's start
     * @param {string} end - Block's end
     * @param {string} startReplacer - Token to use as block's start replacer
     * @param {string} endReplacer - Token to use as block's end replacer
     * @returns {string}
     */
    unescapeBlock = (content, start, end, startReplacer, endReplacer) => {
      const { extractText } = this.lib.aneka
      const { result } = extractText(content, start, end)
      if (result.length === 0) return content
      const unescaped = this.unescape(result)
      const token = `${start}${result}${end}`
      const replacer = `${startReplacer}${unescaped}${endReplacer}`
      const block = content.replaceAll(token, replacer)
      return this.unescapeBlock(block, start, end, startReplacer, endReplacer)
    }

    /**
     * Unescape text using {@link TEscapeChars} rules
     *
     * @method
     * @param {string} text - Text to unescape
     * @returns {string}
     */
    unescape = (text) => {
      const { forOwn, invert } = this.lib._
      const mapping = invert(this.escapeChars)
      forOwn(mapping, (v, k) => {
        text = text.replaceAll(k, v)
      })
      return text
    }
  }

  return Waibu
}

export default factory