waibu
Version:
Web Framework for Bajo
342 lines (313 loc) • 12.3 kB
JavaScript
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 logRoutes from './lib/log-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'
async function factory (pkgName) {
const me = this
return class Waibu extends this.lib.BajoPlugin {
constructor () {
super(pkgName, me.app)
this.alias = 'w'
this.dependencies = ['bajo-logger', 'bajo-extra']
this.config = {
server: {
host: '127.0.0.1',
port: 7771
},
factory: {
trustProxy: true,
bodyLimit: 10485760,
pluginTimeout: 30000
},
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: {},
logRoutes: true,
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.escapeChars = {
'<': '<',
'>': '>',
'"': '"',
"'": '''
}
this.qs = {
parse: (item) => {
return queryString.parse(item, {
parseBooleans: true,
parseNumbers: true
})
},
parseUrl: queryString.parseUrl,
stringify: queryString.stringify,
stringifyUrl: queryString.stringifyUrl
}
this.hookTypes = ['onRequest', 'onResponse', 'preParsing', 'preValidation', 'preHandler',
'preSerialization', 'onSend', 'onTimeout', 'onError']
}
init = async () => {
if (this.config.home === '/') this.config.home = false
await collectRoutePathHandlers.call(this)
}
start = async () => {
const { generateId, runHook } = this.app.bajo
const cfg = this.getConfig()
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.logRoutes) logRoutes.call(this)
}
exit = async () => {
this.instance.close()
}
findRoute = (route) => {
const { outmatch } = this.lib
const { find } = this.lib._
const { breakNsPath } = this.app.bajo
let { ns, subNs = '', path } = breakNsPath(route)
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
})
}
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 = 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
}
getIp = (req) => {
let fwd = req.headers['x-forwarded-for'] ?? ''
if (!Array.isArray(fwd)) fwd = fwd.split(',').map(ip => ip.trim())
return fwd[0] ?? req.ip
}
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}`
}
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]
}
getPluginPrefix = (base, webApp = 'waibuMpa') => {
const { get, trim } = this.lib._
let prefix = get(this, `app.${base}.config.waibu.prefix`, this.app[base].alias)
if (base === 'main') {
const cfg = this.app[webApp].config
if (cfg.mountMainAsRoot) prefix = ''
}
return trim(prefix, '/')
}
getRoutes = (grouped, lite) => {
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
}
getUploadedFiles = async (reqId, fileUrl, returnDir) => {
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
}
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 })
}
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
}
routeDir = (ns, base) => {
const { get } = this.lib._
if (!base) base = ns
const cfg = this.app[base].config
const prefix = get(cfg, 'waibu.prefix', this.app[base].alias)
const dir = prefix === '' ? '' : `/${prefix}`
if (!ns) return dir
const cfgMpa = get(this, 'app.waibuMpa.config')
if (ns === this.app.bajo.mainNs && cfgMpa.mountMainAsRoot) return ''
if (ns === base) return dir
return dir + `/${get(this.app[ns].config, 'waibu.prefix', this.app[ns].alias)}`
}
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 = 'waibu', 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
}
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
}
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) => {
const { forOwn, invert } = this.lib._
const mapping = invert(this.escapeChars)
forOwn(mapping, (v, k) => {
text = text.replaceAll(k, v)
})
return text
}
}
}
export default factory