UNPKG

waibu

Version:

Web Framework for Bajo

613 lines (572 loc) 20.6 kB
import collectRoutePathHandlers from './lib/collect-route-path-handlers.js' import fastify from 'fastify' import handleAppHook from './lib/handle-app-hook.js' import routeHook from './lib/webapp-scope/route-hook.js' import printRoutes from './lib/print-routes.js' import webApp from './lib/web-app.js' import sensible from '@fastify/sensible' import underPressure from '@fastify/under-pressure' import handleForward from './lib/handle-forward.js' import handleRedirect from './lib/handle-redirect.js' import handleError from './lib/handle-error.js' import handleNotFound from './lib/handle-not-found.js' import handleHome from './lib/handle-home.js' import queryString from 'query-string' import decorate from './lib/decorate.js' /** * @typedef TEscapeChars * @type {Object} * @memberof Waibu * @property {string} &lt;=&lt; * @property {string} &gt;=&gt; * @property {string} &quot;=&quot; * @property {string} &apos;=&apos; */ /** * 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, of course, if you want to write custom web apps with its own context. * * @class */ class Waibu extends this.app.baseClass.Base { /** * @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 {TEscapeChars} * @memberof Waibu */ static escapeChars = { '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' } constructor () { super(pkgName, me.app) /** * @see {@tutorial config} * @type {Object} */ this.config = { home: undefined, server: { host: '127.0.0.1', port: 17845 }, factory: { trustProxy: true, bodyLimit: 10485760, pluginTimeout: 30000, routerOptions: { } }, intl: { detectors: ['qs'] }, log: { noReq: false, noReply: false, defer: false }, prefixVirtual: '~', qsKey: { bbox: 'bbox', bboxLatField: 'bboxLatField', bboxLngField: 'bboxLngField', query: 'query', search: 'search', 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 } }, 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 { runHook } = this.app.bajo const { generateId } = this.app.lib.aneka 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) this.instance = fastify(cfg.factory) this.routes = this.routes || [] await decorate.call(this) await runHook('waibu:afterCreateContext', this.instance) await this.instance.register(sensible) if (cfg.underPressure) await this.instance.register(underPressure) await handleRedirect.call(this) await handleForward.call(this) await handleAppHook.call(this) await handleError.call(this) await routeHook.call(this, this.ns) await webApp.call(this) await handleHome.call(this) await handleNotFound.call(this) await this.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, method = 'GET') => { const { outmatch } = this.app.lib const { find, isString } = this.app.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 const methods = isString(r.method) ? [r.method] : r.method return ns === r.config.ns && r.config.subNs === subNs && methods.includes(method) }) } get escapeChars () { return this.constructor.escapeChars } /** * Escape text * * @method * @param {string} text * @returns {string} */ escape = (text) => { const { isSet } = this.app.lib.aneka const { isArray, isPlainObject, cloneDeep } = this.app.lib._ if (!isSet(text)) return '' if (isArray(text) || isPlainObject(text)) text = JSON.stringify(cloneDeep(text)) else text = text + '' const { forOwn } = this.app.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.app.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 from fastify's request object * * @method * @param {Object} req * @returns {string} */ getOrigin = (req) => { const { isEmpty } = this.app.lib._ let host = req.host if (isEmpty(host) || host === ':authority') host = `${this.config.server.host}:${this.config.server.port}` return `${req.protocol}://${host}` } /** * Get hostname from fastify's request object * * @param {Object} req * @returns {string} */ getHostname = (req) => { return req.hostname.split(':')[0] } /** * Get plugin by prefix * * @method * @param {string} prefix * @param {boolean} nsOnly - Set ```true``` to return plugin's namespace only * @returns {Object} */ getPluginByPrefix = (prefix, nsOnly) => { const { get, find } = this.app.lib._ const ns = find(this.app.getAllNs(), p => { return get(this, `app.${p}.config.waibu.prefix`) === prefix }) if (!ns) return return nsOnly ? ns : 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.app.lib._ let prefix = get(this, `app.${name}.config.${webApp}.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.app.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 } = this.app.bajo const { resolvePath } = this.app.lib.aneka const { fastGlob } = this.app.lib const dir = `${getPluginDataDir(this.ns)}/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.app.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.app.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.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 starts with ```:/```, name will be prefixed with its ```ns``` automatically * - If it is a ns based name, it will be parsed first * * @method * @param {string} name * @param {Object} [options={}] - Options object * @param {string} [options.ns=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.app.lib.aneka const { isEmpty, get, trimEnd, trimStart } = this.app.lib._ const { breakNsPath } = this.app.bajo const { query = {}, ns = this.ns, params = {}, guessHost, defaults = {}, uriEncoded } = options const plugin = getPlugin(ns) const cfg = plugin.config ?? {} let info = {} if (name.startsWith('mailto:') || name.startsWith('tel:')) return name if (name.slice(0, 2) === ':/') name = ns + 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 => { if (!(p[0] === ':' || (p[0] === '{' && p[p.length - 1] === '}'))) return p const _p = p p = p.replace(':', '').replace('{', '').replace('}', '') if (params[p]) return params[p] if (defaults[p]) return defaults[p] return _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 (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 } /** * 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.app.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.app.lib._ const mapping = invert(this.escapeChars) forOwn(mapping, (v, k) => { text = text.replaceAll(k, v) }) return text } arrayToAttr = (array = [], delimiter = ' ') => { const { isPlainObject } = this.app.lib._ return array.map(item => { if (isPlainObject(item)) return this.objectToAttr(item) return item }).join(delimiter) } attrToArray = (text = '', delimiter = ' ') => { const { map, trim, without, isArray } = this.app.lib._ if (text === true) text = '' if (isArray(text)) text = text.join(delimiter) return without(map(text.split(delimiter), i => trim(i)), '', undefined, null).map(item => { return item }) } attrToObject = (text = '', delimiter = ';', kvDelimiter = ':') => { const { camelCase, isPlainObject } = this.app.lib._ const result = {} if (isPlainObject(text)) text = this.objectToAttr(text) if (typeof text !== 'string') return text if (text.slice(1, 3) === '%=') return text const array = this.attrToArray(text, delimiter) array.forEach(item => { const [key, val] = this.attrToArray(item, kvDelimiter) result[camelCase(key)] = val }) return result } base64JsonDecode = (data = 'e30=') => { return JSON.parse(Buffer.from(data, 'base64')) } base64JsonEncode = (data) => { return Buffer.from(JSON.stringify(data)).toString('base64') } objectToAttr = (obj = {}, delimiter = ';', kvDelimiter = ':') => { const { forOwn, kebabCase } = this.app.lib._ const result = [] forOwn(obj, (v, k) => { result.push(`${kebabCase(k)}${kvDelimiter}${v ?? ''}`) }) return result.join(delimiter) } getSetting = (key, { defValue, req = {} } = {}) => { const { breakNsPath } = this.app.bajo const { get, isPlainObject, isArray } = this.app.lib._ const { defaultsDeep } = this.app.lib.aneka let { ns, path } = breakNsPath(key) const paths = path.replaceAll('/', '.').split('.') if (paths[0] === '') paths.shift() path = paths.join('.') const cfgValue = get(this.app, `${ns}.config.${path}`) const reqValue = get(req, `site.setting.${ns}.${path}`) if (isPlainObject(cfgValue) || isArray(cfgValue)) return defaultsDeep({}, reqValue, cfgValue, defValue) return reqValue ?? cfgValue ?? defValue } } return Waibu } export default factory