waibu 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/bajo/config.json +47 -0
  4. package/bajo/exit.js +3 -0
  5. package/bajo/hook/bajoI18N@before-init.js +6 -0
  6. package/bajo/hook/on-close.js +5 -0
  7. package/bajo/hook/on-ready.js +5 -0
  8. package/bajo/hook/on-request.js +20 -0
  9. package/bajo/hook/on-response.js +12 -0
  10. package/bajo/hook/on-route.js +8 -0
  11. package/bajo/init.js +5 -0
  12. package/bajo/method/get-ip.js +7 -0
  13. package/bajo/method/get-params.js +18 -0
  14. package/bajo/method/get-routes.js +13 -0
  15. package/bajo/method/get-uploaded-files.js +11 -0
  16. package/bajo/method/hook-types.js +4 -0
  17. package/bajo/method/merge-route-hooks.js +19 -0
  18. package/bajo/method/method-map.js +9 -0
  19. package/bajo/method/not-found.js +5 -0
  20. package/bajo/method/parse-filter.js +10 -0
  21. package/bajo/method/record/create.js +13 -0
  22. package/bajo/method/record/find-one.js +19 -0
  23. package/bajo/method/record/find.js +20 -0
  24. package/bajo/method/record/get.js +14 -0
  25. package/bajo/method/record/remove.js +10 -0
  26. package/bajo/method/record/update.js +13 -0
  27. package/bajo/method/route-dir.js +10 -0
  28. package/bajo/method/route-path.js +25 -0
  29. package/bajo/method/stat/aggregate.js +13 -0
  30. package/bajo/method/stat/histogram.js +13 -0
  31. package/bajo/start.js +41 -0
  32. package/bajoI18N/resource/id.json +46 -0
  33. package/lib/app-hook.js +16 -0
  34. package/lib/app.js +35 -0
  35. package/lib/handle-forward.js +26 -0
  36. package/lib/handle-redirect.js +11 -0
  37. package/lib/handle-xml-body.js +54 -0
  38. package/lib/home.js +15 -0
  39. package/lib/log-routes.js +19 -0
  40. package/lib/prep-crud.js +17 -0
  41. package/lib/webapp-scope/attach-i18n.js +63 -0
  42. package/lib/webapp-scope/error-handler.js +22 -0
  43. package/lib/webapp-scope/handle-compress.js +8 -0
  44. package/lib/webapp-scope/handle-cors.js +8 -0
  45. package/lib/webapp-scope/handle-helmet.js +8 -0
  46. package/lib/webapp-scope/handle-multipart-body.js +72 -0
  47. package/lib/webapp-scope/handle-rate-limit.js +9 -0
  48. package/lib/webapp-scope/is-route-disabled.js +28 -0
  49. package/lib/webapp-scope/rerouted-path.js +12 -0
  50. package/lib/webapp-scope/route-hook.js +13 -0
  51. package/package.json +46 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Ardhi Lukianto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # wakatobi
2
+
3
+ Plugin name: **wakatobi**, alias: **wktb**
4
+
5
+ ![GitHub package.json version](https://img.shields.io/github/package-json/v/ardhi/wakatobi) ![NPM Version](https://img.shields.io/npm/v/wakatobi)
6
+
7
+ > <br />**Attention**: I do NOT accept any pull request at the moment, thanks!<br /><br />
8
+
9
+ [Bajo](https://github.com/ardhi/bajo)'s Web Framework. Entirely based on [Fastify](https://github.com/fastify/fastify) and its ecosystem.
10
+
11
+ ## Installation
12
+
13
+ Goto your ```<bajo-base-dir>``` and type:
14
+
15
+ ```bash
16
+ $ npm install wakatobi
17
+ ```
18
+
19
+ Now open your ```<bajo-data-dir>/config/.plugins``` and put ```wakatobi``` in it
20
+
21
+ ## License
22
+
23
+ [MIT](LICENSE)
@@ -0,0 +1,47 @@
1
+ {
2
+ "alias": "wktb",
3
+ "server": {
4
+ "host": "localhost",
5
+ "port": 7777
6
+ },
7
+ "factory": {
8
+ "trustProxy": true
9
+ },
10
+ "qsKey": {
11
+ "bbox": "bbox",
12
+ "bboxLatField": "bboxLatField",
13
+ "bboxLngField": "bboxLngField",
14
+ "query": "query",
15
+ "match": "match",
16
+ "skip": "skip",
17
+ "page": "page",
18
+ "limit": "limit",
19
+ "sort": "sort",
20
+ "fields": "fields",
21
+ "lang": "lang"
22
+ },
23
+ "paramsCharMap": {},
24
+ "dbColl": {
25
+ "dataOnly": false,
26
+ "count": false,
27
+ "patchEnabled": false
28
+ },
29
+ "logRoutes": false,
30
+ "siteInfo": {
31
+ "title": "My Website",
32
+ "orgName": "My Organization"
33
+ },
34
+ "multipart": {
35
+ "attachFieldsToBody": true,
36
+ "limits": {
37
+ "parts": 100,
38
+ "fileSize": 10485760
39
+ }
40
+ },
41
+ "noIcon": true,
42
+ "underPressure": false,
43
+ "forwardOpts": {
44
+ "disableRequestLogging": true
45
+ },
46
+ "dependencies": ["bajo-logger"]
47
+ }
package/bajo/exit.js ADDED
@@ -0,0 +1,3 @@
1
+ export default async function () {
2
+ this.instance.close()
3
+ }
@@ -0,0 +1,6 @@
1
+ async function bajoI18nBeforeInit () {
2
+ const config = this.app.bajoI18N.config
3
+ if (!config.fallbackNS.includes(this.name)) config.fallbackNS.push(this.name)
4
+ }
5
+
6
+ export default bajoI18nBeforeInit
@@ -0,0 +1,5 @@
1
+ async function onClose () {
2
+ this.log.info('Server is closed')
3
+ }
4
+
5
+ export default onClose
@@ -0,0 +1,5 @@
1
+ async function onReady () {
2
+ this.log.info('Server is ready!')
3
+ }
4
+
5
+ export default onReady
@@ -0,0 +1,20 @@
1
+ const onRequest = {
2
+ level: 5,
3
+ handler: async function onRequest (ctx, req, reply) {
4
+ req.site = this.config.siteInfo
5
+ let msg = '< %s:%s from IP %s'
6
+ if (req.headers['content-length']) msg += ', content length: %s'
7
+ this.log.info(msg, req.method, req.url, this.getIp(req), req.headers['content-length'])
8
+ if (Object.keys(this.config.paramsCharMap).length === 0) return
9
+ for (const key in req.params) {
10
+ let val = req.params[key]
11
+ if (typeof val !== 'string') continue
12
+ for (const char in this.config.paramsCharMap) {
13
+ val = val.replaceAll(char, this.config.paramsCharMap[char])
14
+ }
15
+ req.params[key] = val
16
+ }
17
+ }
18
+ }
19
+
20
+ export default onRequest
@@ -0,0 +1,12 @@
1
+ const onResponse = {
2
+ level: 5,
3
+ handler: async function onResponse (ctx, req, reply) {
4
+ let method = 'info'
5
+ if (reply.statusCode >= 300 && reply.statusCode < 400) method = 'warn'
6
+ else if (reply.statusCode >= 400) method = 'error'
7
+ this.log[method]('> %s:%s with a %d-status took %dms', req.method, req.url, reply.statusCode,
8
+ (reply.elapsedTime ?? 0).toFixed(3))
9
+ }
10
+ }
11
+
12
+ export default onResponse
@@ -0,0 +1,8 @@
1
+ const onRoute = {
2
+ level: 5,
3
+ handler: async function (ctx, opts) {
4
+ this.routes.push(opts)
5
+ }
6
+ }
7
+
8
+ export default onRoute
package/bajo/init.js ADDED
@@ -0,0 +1,5 @@
1
+ async function init () {
2
+ if (this.config.home === '/') this.config.home = false
3
+ }
4
+
5
+ export default init
@@ -0,0 +1,7 @@
1
+ function getIp (req) {
2
+ let fwd = req.headers['x-forwarded-for'] ?? ''
3
+ if (!Array.isArray(fwd)) fwd = fwd.split(',').map(ip => ip.trim())
4
+ return fwd[0] ?? req.ip
5
+ }
6
+
7
+ export default getIp
@@ -0,0 +1,18 @@
1
+ function getParams (req, ...items) {
2
+ const { map, trim, get } = this.app.bajo.lib._
3
+ let fields
4
+ req.query = req.query ?? {}
5
+ req.params = req.params ?? {}
6
+ if (req.query.fields) fields = map((req.query.fields ?? '').split(','), i => trim(i))
7
+ const params = {
8
+ fields,
9
+ count: get(this, 'config.dbColl.count', false),
10
+ body: req.body
11
+ }
12
+ items.forEach(i => {
13
+ params[i] = req.params[i]
14
+ })
15
+ return params
16
+ }
17
+
18
+ export default getParams
@@ -0,0 +1,13 @@
1
+ function getRoutes (grouped, lite) {
2
+ const { groupBy, orderBy, mapValues, map, pick } = this.app.bajo.lib._
3
+ const all = this.routes
4
+ let routes
5
+ if (grouped) {
6
+ const group = groupBy(orderBy(all, ['url', 'method']), 'url')
7
+ routes = lite ? mapValues(group, (v, k) => map(v, 'method')) : group
8
+ } else if (lite) routes = map(all, a => pick(a, ['url', 'method']))
9
+ else routes = all
10
+ return routes
11
+ }
12
+
13
+ export default getRoutes
@@ -0,0 +1,11 @@
1
+ async function getUploadedFiles (reqId, fileUrl, returnDir) {
2
+ const { getPluginDataDir, resolvePath } = this.app.bajo
3
+ const { fastGlob } = this.app.bajo.lib
4
+ const dir = `${getPluginDataDir(this.name)}/upload/${reqId}`
5
+ const result = await fastGlob(`${dir}/*`)
6
+ if (!fileUrl) return returnDir ? { dir, files: result } : result
7
+ const files = result.map(f => resolvePath(f, true))
8
+ return returnDir ? { dir, files } : files
9
+ }
10
+
11
+ export default getUploadedFiles
@@ -0,0 +1,4 @@
1
+ const hookTypes = ['onRequest', 'onResponse', 'preParsing', 'preValidation', 'preHandler',
2
+ 'preSerialization', 'onSend', 'onTimeout', 'onError']
3
+
4
+ export default hookTypes
@@ -0,0 +1,19 @@
1
+ import hookTypes from './hook-types.js'
2
+
3
+ async function mergeRouteHooks (def, withHandler = true) {
4
+ const { last, isFunction } = this.app.bajo.lib._
5
+ const hooks = [...hookTypes]
6
+ const me = this
7
+ if (withHandler) hooks.push('handler')
8
+ for (const h of hooks) {
9
+ const oldH = def[h]
10
+ if (!oldH) continue
11
+ def[h] = async function (...args) {
12
+ // TODO: hooks can be array of functions
13
+ if (isFunction(last(args))) args.pop()
14
+ return await oldH.call(me, this, ...args)
15
+ }
16
+ }
17
+ }
18
+
19
+ export default mergeRouteHooks
@@ -0,0 +1,9 @@
1
+ const methodMap = {
2
+ create: 'POST',
3
+ find: 'GET',
4
+ get: 'GET',
5
+ update: 'PUT',
6
+ remove: 'DELETE'
7
+ }
8
+
9
+ export default methodMap
@@ -0,0 +1,5 @@
1
+ function notFound (name, options) {
2
+ throw this.error('notfound', { path: name })
3
+ }
4
+
5
+ export default notFound
@@ -0,0 +1,10 @@
1
+ function parseFilter (req) {
2
+ const result = {}
3
+ const items = Object.keys(this.config.qsKey)
4
+ for (const item of items) {
5
+ result[item] = req.query[this.config.qsKey[item]]
6
+ }
7
+ return result
8
+ }
9
+
10
+ export default parseFilter
@@ -0,0 +1,13 @@
1
+ import prepCrud from '../../../lib/prep-crud.js'
2
+
3
+ async function create ({ coll, req, reply, body, options = {} }) {
4
+ this.app.bajo.getPlugin('dobo') // ensure dobo is loaded
5
+ const { recordCreate, attachmentFind } = this.app.dobo
6
+ const { name, input, opts } = prepCrud.call(this, { coll, req, body, options, args: ['coll'] })
7
+ const ret = await recordCreate(name, input, opts)
8
+ const { attachment, stats, mimeType } = req.query
9
+ if (attachment) ret.data._attachment = await attachmentFind(name, ret.data.id, { stats, mimeType })
10
+ return ret
11
+ }
12
+
13
+ export default create
@@ -0,0 +1,19 @@
1
+ import prepCrud from '../../../lib/prep-crud.js'
2
+
3
+ async function find ({ coll, req, reply, options = {} }) {
4
+ this.app.bajo.getPlugin('dobo') // ensure dobo is loaded
5
+ const { recordFindOne, attachmentFind } = this.app.dobo
6
+ const { name, opts } = prepCrud.call(this, { coll, req, options, args: ['coll'] })
7
+ opts.bboxLatField = req.query[this.config.qsKey.bboxLatField]
8
+ opts.bboxLngField = req.query[this.config.qsKey.bboxLngField]
9
+ const filter = this.parseFilter(req)
10
+ const ret = await recordFindOne(name, filter, opts)
11
+ ret.filter = filter
12
+ const { attachment, stats, mimeType } = req.query
13
+ if (attachment) {
14
+ ret.data._attachment = await attachmentFind(name, ret.data.id, { stats, mimeType })
15
+ }
16
+ return ret
17
+ }
18
+
19
+ export default find
@@ -0,0 +1,20 @@
1
+ import prepCrud from '../../../lib/prep-crud.js'
2
+
3
+ async function find ({ coll, req, reply, options = {} }) {
4
+ this.app.bajo.getPlugin('dobo') // ensure dobo is loaded
5
+ const { recordFind, attachmentFind } = this.app.dobo
6
+ const { name, opts } = prepCrud.call(this, { coll, req, options, args: ['coll'] })
7
+ opts.bboxLatField = req.query[this.config.qsKey.bboxLatField]
8
+ opts.bboxLngField = req.query[this.config.qsKey.bboxLngField]
9
+ const filter = this.parseFilter(req)
10
+ const ret = await recordFind(name, filter, opts)
11
+ const { attachment, stats, mimeType } = req.query
12
+ if (attachment) {
13
+ for (const d of ret.data) {
14
+ d._attachment = await attachmentFind(name, d.id, { stats, mimeType })
15
+ }
16
+ }
17
+ return ret
18
+ }
19
+
20
+ export default find
@@ -0,0 +1,14 @@
1
+ import prepCrud from '../../../lib/prep-crud.js'
2
+
3
+ async function get ({ coll, req, reply, id, options = {} }) {
4
+ this.app.bajo.getPlugin('dobo') // ensure dobo is loaded
5
+ const { recordGet, attachmentFind } = this.app.dobo
6
+ const { name, recId, opts } = prepCrud.call(this, { coll, req, id, options, args: ['coll', 'id'] })
7
+ opts.filter = this.parseFilter(req)
8
+ const ret = await recordGet(name, recId, opts)
9
+ const { attachment, stats, mimeType } = req.query
10
+ if (attachment) ret.data._attachment = await attachmentFind(name, id, { stats, mimeType })
11
+ return ret
12
+ }
13
+
14
+ export default get
@@ -0,0 +1,10 @@
1
+ import prepCrud from '../../../lib/prep-crud.js'
2
+
3
+ async function remove ({ coll, req, reply, id, options = {} }) {
4
+ this.app.bajo.getPlugin('dobo') // ensure dobo is loaded
5
+ const { recordRemove } = this.app.dobo
6
+ const { name, recId, opts } = prepCrud.call(this, { coll, req, id, options, args: ['coll', 'id'] })
7
+ return await recordRemove(name, recId, opts)
8
+ }
9
+
10
+ export default remove
@@ -0,0 +1,13 @@
1
+ import prepCrud from '../../../lib/prep-crud.js'
2
+
3
+ async function update ({ coll, req, reply, id, body, options = {} }) {
4
+ this.app.bajo.getPlugin('dobo') // ensure dobo is loaded
5
+ const { recordUpdate, attachmentFind } = this.app.dobo
6
+ const { name, input, opts, recId } = prepCrud.call(this, { coll, req, body, id, options, args: ['coll', 'id'] })
7
+ const ret = await recordUpdate(name, recId, input, opts)
8
+ const { attachment, stats, mimeType } = req.query
9
+ if (attachment) ret.data._attachment = await attachmentFind(name, id, { stats, mimeType })
10
+ return ret
11
+ }
12
+
13
+ export default update
@@ -0,0 +1,10 @@
1
+ function routeDir (ns, base) {
2
+ if (!base) base = ns
3
+ const cfg = this.app[base].config
4
+ const dir = cfg.prefix === '' ? '' : `/${cfg.prefix || this.app[base].alias}`
5
+ if (!ns) return dir
6
+ if (ns === base || (ns === this.app.bajo.mainNs && cfg.mountAppAsRoot)) return dir
7
+ return dir + '/' + this.app[ns].alias
8
+ }
9
+
10
+ export default routeDir
@@ -0,0 +1,25 @@
1
+ import qs from 'querystring'
2
+
3
+ function routePath (name, { query = {}, base = 'wakatobiMpa', params = {} } = {}) {
4
+ // TODO: what if wakatobiMpa isn't loaded?
5
+ const { defaultsDeep } = this.app.bajo
6
+ const { isEmpty, get } = this.app.bajo.lib._
7
+ const { breakNsPath } = this.app.bajo
8
+ const cfg = this.app[base].config ?? {}
9
+ let ns
10
+ let fullPath
11
+ if (name.startsWith('/')) fullPath = name
12
+ else [ns, fullPath] = breakNsPath(name)
13
+ let [path, queryString] = fullPath.split('?')
14
+ path = path.split('/').map(p => {
15
+ return p[0] === ':' && params[p.slice(1)] ? params[p.slice(1)] : p
16
+ }).join('/')
17
+ let url = path
18
+ const langDetector = get(cfg, 'i18n.detectors', [])
19
+ if (ns) url = langDetector.includes('path') ? `/${params.lang ?? ''}${this.routeDir(ns)}${path}` : `${this.routeDir(ns)}${path}`
20
+ queryString = defaultsDeep(query, qs.parse(queryString))
21
+ if (!isEmpty(queryString)) url += '?' + qs.stringify(queryString)
22
+ return url
23
+ }
24
+
25
+ export default routePath
@@ -0,0 +1,13 @@
1
+ import prepCrud from '../../../lib/prep-crud.js'
2
+
3
+ async function aggregate ({ coll, req, reply, options = {} }) {
4
+ this.app.bajo.getPlugin('dobo') // ensure dobo is loaded
5
+ const { statAggregate } = this.app.dobo
6
+ const { name, opts } = prepCrud.call(this, { coll, req, options, args: ['coll'] })
7
+ for (const item of ['group', 'aggregate']) {
8
+ opts[item] = options[item] ?? req.params[item] ?? req.query[item]
9
+ }
10
+ return await statAggregate(name, this.parseFilter(req), opts)
11
+ }
12
+
13
+ export default aggregate
@@ -0,0 +1,13 @@
1
+ import prepCrud from '../../../lib/prep-crud.js'
2
+
3
+ async function histogram ({ coll, req, reply, options = {} }) {
4
+ this.app.bajo.getPlugin('dobo') // ensure dobo is loaded
5
+ const { statHistogram } = this.app.dobo
6
+ const { name, opts } = prepCrud.call(this, { coll, req, options, args: ['coll'] })
7
+ for (const item of ['type', 'group', 'aggregate']) {
8
+ opts[item] = options[item] ?? req.params[item] ?? req.query[item]
9
+ }
10
+ return await statHistogram(name, this.parseFilter(req), opts)
11
+ }
12
+
13
+ export default histogram
package/bajo/start.js ADDED
@@ -0,0 +1,41 @@
1
+ import fastify from 'fastify'
2
+ import appHook from '../lib/app-hook.js'
3
+ import routeHook from '../lib/webapp-scope/route-hook.js'
4
+ import logRoutes from '../lib/log-routes.js'
5
+ import { boot } from '../lib/app.js'
6
+ import sensible from '@fastify/sensible'
7
+ import noIcon from 'fastify-no-icon'
8
+ import underPressure from '@fastify/under-pressure'
9
+ import handleForward from '../lib/handle-forward.js'
10
+ import handleRedirect from '../lib/handle-redirect.js'
11
+
12
+ async function start () {
13
+ const { generateId, runHook } = this.app.bajo
14
+ const cfg = this.getConfig()
15
+ cfg.factory.logger = this.app.bajoLogger.instance.child(
16
+ {},
17
+ { msgPrefix: '[wakatobi] ' }
18
+ )
19
+ cfg.factory.genReqId = req => generateId()
20
+ cfg.factory.disableRequestLogging = true
21
+
22
+ const instance = fastify(cfg.factory)
23
+ instance.decorateRequest('lang', null)
24
+ instance.decorateRequest('langDetector', null)
25
+ instance.decorateRequest('site', null)
26
+ this.instance = instance
27
+ this.routes = this.routes || []
28
+ await runHook('wakatobi:afterCreateContext', instance)
29
+ await instance.register(sensible)
30
+ if (cfg.underPressure) await instance.register(underPressure)
31
+ if (cfg.noIcon) await instance.register(noIcon)
32
+ await handleRedirect.call(this, instance)
33
+ await handleForward.call(this, instance)
34
+ await appHook.call(this)
35
+ await routeHook.call(this, this.name)
36
+ await boot.call(this)
37
+ await instance.listen(cfg.server)
38
+ if (cfg.logRoutes) logRoutes.call(this)
39
+ }
40
+
41
+ export default start
@@ -0,0 +1,46 @@
1
+ {
2
+ "[%s] Server is closed": "[%s] Penyelia telah tertutup",
3
+ "[%s] Server is ready!": "[%s] Penyelia telah siap!",
4
+ "Route '%s (%s)' not found": "Rute '%s (%s)' tidak ditemukan",
5
+ "Bad Request": "",
6
+ "Unauthorized": "Tidak Terautentikasi",
7
+ "Payment Required": "Pembayaran Disyaratkan",
8
+ "Forbidden": "Dilarang",
9
+ "Not Found": "Tidak Ditemukan",
10
+ "Method Not Allowed": "Metode Tidak Diperkenankan",
11
+ "Not Acceptable": "Tidak Bisa Diterima",
12
+ "Proxy Authentication Required": "",
13
+ "Request Timeout": "",
14
+ "Conflict": "",
15
+ "Gone": "",
16
+ "Length Required": "",
17
+ "Precondition Failed": "",
18
+ "Payload Too Large": "",
19
+ "URI Too Long": "",
20
+ "Unsupported Media Type": "",
21
+ "Range Not Satisfiable": "",
22
+ "Expectation Failed": "",
23
+ "Im A Teapot": "",
24
+ "Misdirected Request": "",
25
+ "Unprocessable Entity": "",
26
+ "Locked": "",
27
+ "Failed Dependency": "",
28
+ "Too Early": "",
29
+ "Upgrade Required": "",
30
+ "Precondition Required": "",
31
+ "Too Many Requests": "",
32
+ "Request Header Fields Too Large": "",
33
+ "Unavailable For Legal Reasons": "",
34
+ "Internal Server Error": "",
35
+ "Not Implemented": "",
36
+ "Bad Gateway": "",
37
+ "Service Unavailable": "",
38
+ "Gateway Timeout": "",
39
+ "HTTP Version Not Supported": "",
40
+ "Variant Also Negotiates": "",
41
+ "Insufficient Storage": "",
42
+ "Loop Detected": "",
43
+ "Bandwidth Limit Exceeded": "",
44
+ "Not Extended": "",
45
+ "Network Authentication Require": ""
46
+ }
@@ -0,0 +1,16 @@
1
+ async function appHook () {
2
+ const { runHook } = this.app.bajo
3
+ const hooks = ['onReady', 'onClose', 'preClose', 'onRoute', 'onRegister']
4
+ const me = this
5
+ for (const hook of hooks) {
6
+ me.instance.addHook(hook, async function (...args) {
7
+ if (['onClose', 'onReady'].includes(hook)) await runHook(`${me.name}:${hook}`, ...args)
8
+ else {
9
+ const ctx = this // encapsulated fastify scope
10
+ await runHook(`${me.name}:${hook}`, ctx, ...args)
11
+ }
12
+ })
13
+ }
14
+ }
15
+
16
+ export default appHook
package/lib/app.js ADDED
@@ -0,0 +1,35 @@
1
+ import home from './home.js'
2
+
3
+ export async function collect (glob = 'boot.js', baseNs) {
4
+ const { eachPlugins, importModule } = this.app.bajo
5
+ const { orderBy } = this.app.bajo.lib._
6
+ if (!baseNs) baseNs = this.name
7
+ const mods = []
8
+ await eachPlugins(async function ({ config, file, ns, alias }) {
9
+ const mod = await importModule(file, { asHandler: true })
10
+ mod.prefix = config.prefix ?? alias
11
+ mod.ns = ns
12
+ mods.push(mod)
13
+ }, { glob, baseNs })
14
+ const prefixes = {}
15
+ mods.forEach(m => {
16
+ if (!prefixes[m.prefix]) prefixes[m.prefix] = []
17
+ prefixes[m.prefix].push(m.plugin)
18
+ if (prefixes[m.prefix].length > 1) this.fatal('Plugin prefix \'%s\' conflic between \'%s\' and \'%s\'', m.prefix, prefixes[m.prefix][0], prefixes[m.prefix][1])
19
+ })
20
+ return orderBy(mods, ['level'])
21
+ }
22
+
23
+ export async function boot () {
24
+ const { runHook } = this.app.bajo
25
+ const mods = await collect.call(this)
26
+ await runHook(`${this.name}:beforeAppBoot`)
27
+ for (const m of mods) {
28
+ await runHook(`${this.name}.${m.ns}:beforeAppBoot`)
29
+ this.log.debug('Boot app: %s', m.ns)
30
+ await m.handler.call(this.app[m.ns])
31
+ await runHook(`${this.name}.${m.ns}:afterAppBoot`)
32
+ }
33
+ await runHook(`${this.name}:afterAppBoot`)
34
+ await home.call(this)
35
+ }
@@ -0,0 +1,26 @@
1
+ import replyFrom from '@fastify/reply-from'
2
+
3
+ async function handleForward (ctx) {
4
+ const { defaultsDeep } = this.app.bajo
5
+ const me = this
6
+
7
+ function rewriteHeaders (headers, req) {
8
+ return {
9
+ ...headers,
10
+ 'X-Fwd-To': true
11
+ }
12
+ }
13
+
14
+ const base = `http://${this.config.server.host}:${this.config.server.port}`
15
+ const options = defaultsDeep({ base }, this.config.forwardOpts)
16
+ await ctx.register(replyFrom, options)
17
+
18
+ await ctx.decorateReply('forwardTo', async function (url, options = {}) {
19
+ if (url.startsWith('http')) return await me.redirectTo(url)
20
+ return this.from(me.routePath(url, options), {
21
+ rewriteHeaders
22
+ })
23
+ })
24
+ }
25
+
26
+ export default handleForward
@@ -0,0 +1,11 @@
1
+ import path from 'path'
2
+
3
+ async function handleRedirect (ctx, options) {
4
+ const me = this
5
+ ctx.decorateReply('redirectTo', async function (url, options = {}) {
6
+ if (url.startsWith('http') || path.isAbsolute(url)) return this.redirect(url)
7
+ return this.redirect(me.routePath(url, options))
8
+ })
9
+ }
10
+
11
+ export default handleRedirect
@@ -0,0 +1,54 @@
1
+ // based on: https://github.com/NaturalIntelligence/fastify-xml-body-parser/blob/master/index.js
2
+
3
+ async function xmlBodyParser (ctx, opts = {}) {
4
+ if (!this.app.bajoExtra) {
5
+ this.log.warn('Can\'t parse XML body unless package \'%s\' is loaded first', 'bajo-extra')
6
+ return
7
+ }
8
+ const { importPkg } = this.app.bajo
9
+ const fxp = await importPkg('bajoExtra:fast-xml-parser')
10
+
11
+ function contentParser (req, payload, done) {
12
+ const xmlParser = new fxp.XMLParser(opts)
13
+ const parsingOpts = opts
14
+ let body = ''
15
+ payload.on('error', errorListener)
16
+ payload.on('data', dataListener)
17
+ payload.on('end', endListener)
18
+
19
+ function errorListener (err) {
20
+ done(err)
21
+ }
22
+ function endListener () {
23
+ if (parsingOpts.validate) {
24
+ const result = fxp.XMLValidator.validate(body, parsingOpts)
25
+ if (result.err) {
26
+ const invalidFormat = new Error('Invalid Format: ' + result.err.msg)
27
+ invalidFormat.statusCode = 400
28
+ payload.removeListener('error', errorListener)
29
+ payload.removeListener('data', dataListener)
30
+ payload.removeListener('end', endListener)
31
+ done(invalidFormat)
32
+ } else {
33
+ handleParseXml(body)
34
+ }
35
+ } else {
36
+ handleParseXml(body)
37
+ }
38
+ }
39
+ function dataListener (data) {
40
+ body = body + data
41
+ }
42
+ function handleParseXml (body) {
43
+ try {
44
+ done(null, xmlParser.parse(body))
45
+ } catch (err) {
46
+ done(err)
47
+ }
48
+ }
49
+ }
50
+
51
+ ctx.addContentTypeParser(opts.contentTypes, contentParser)
52
+ }
53
+
54
+ export default xmlBodyParser
package/lib/home.js ADDED
@@ -0,0 +1,15 @@
1
+ async function home () {
2
+ const { defaultsDeep } = this.app.bajo
3
+ const { isString, pick } = this.app.bajo.lib._
4
+ const config = this.getConfig()
5
+ if (config.home) {
6
+ if (isString(config.home)) config.home = { path: config.home }
7
+ await this.instance.get('/', async function (req, reply) {
8
+ if (!config.home.forward) return await reply.redirectTo(config.home.path)
9
+ const opts = defaultsDeep(pick(req, ['params', 'query']), pick(config.home, ['params', 'query']))
10
+ return await reply.forwardTo(config.home.path, opts)
11
+ })
12
+ }
13
+ }
14
+
15
+ export default home
@@ -0,0 +1,19 @@
1
+ function printRoutes () {
2
+ const { findIndex, orderBy, isArray } = this.app.bajo.lib._
3
+ let items = []
4
+ this.routes.forEach(r => {
5
+ const idx = findIndex(items, { url: r.url })
6
+ if (idx < 0) items.push({ url: r.url, methods: isArray(r.method) ? r.method : [r.method] })
7
+ else {
8
+ if (isArray(r.method)) items[idx].methods.push(...r.method)
9
+ else items[idx].methods.push(r.method)
10
+ }
11
+ })
12
+ items = orderBy(items, ['url'])
13
+ this.log.debug('Loaded routes')
14
+ items.forEach(item => {
15
+ this.log.debug('- %s (%s)', item.url, item.methods.join('|'))
16
+ })
17
+ }
18
+
19
+ export default printRoutes
@@ -0,0 +1,17 @@
1
+ function prepCrud ({ coll, body, id, req, options, args }) {
2
+ const { pascalCase } = this.app.bajo
3
+ const { cloneDeep, get } = this.app.bajo.lib._
4
+ const opts = cloneDeep(options)
5
+ const params = this.getParams(req, ...args)
6
+ for (const k of ['count', 'fields']) {
7
+ opts[k] = opts[k] ?? params[k]
8
+ }
9
+ opts.dataOnly = get(this, 'config.dbColl.dataOnly', false)
10
+ opts.req = req
11
+ const recId = id ?? params.id ?? req.query.id
12
+ const name = pascalCase(coll ?? params.coll)
13
+ const input = body ?? params.body
14
+ return { name, recId, input, opts }
15
+ }
16
+
17
+ export default prepCrud
@@ -0,0 +1,63 @@
1
+ function detect (detector = [], req, reply) {
2
+ const { get, map, trim, orderBy } = this.app.bajo.lib._
3
+ const supported = get(this, 'app.bajoI18N.config.supportedLngs', [this.app.bajo.config.lang])
4
+ const defLang = get(this, 'app.bajoI18N.config.lang', this.app.bajo.config.lang)
5
+ let lang = null
6
+ // by route path
7
+ if (detector.includes('path')) {
8
+ lang = req.params.lang
9
+ if (lang && supported.includes(lang)) {
10
+ req.lang = lang
11
+ req.langDetector = 'path'
12
+ return
13
+ }
14
+ const length = req.url.split('/').length
15
+ let url = req.url.replace(`/${lang}`, `/${defLang}`)
16
+ if (length > 2) url = req.url.replace(`/${lang}/`, `/${defLang}/`)
17
+ reply.redirectTo(url)
18
+ return
19
+ }
20
+ // by query string
21
+ lang = null
22
+ if (detector.includes('qs')) {
23
+ lang = req.query[this.app.wakatobi.config.qsKey.lang]
24
+ if (lang && supported.includes(lang)) {
25
+ req.lang = lang
26
+ req.langDetector = 'qs'
27
+ return
28
+ }
29
+ }
30
+ // by header
31
+ if (detector.includes('header')) {
32
+ lang = null
33
+ const accepteds = orderBy(map((req.headers['accept-language'] || '').split(','), a => {
34
+ const [name, qty] = trim(a || '').split(';').map(i => trim(i))
35
+ return { name, qty: parseFloat((qty || '').split('=')[1]) || 1 }
36
+ }), ['qty'], ['desc'])
37
+ for (const a of accepteds) {
38
+ if (supported.includes(a.name)) {
39
+ lang = a.name
40
+ break
41
+ }
42
+ }
43
+ if (lang) {
44
+ req.lang = lang
45
+ req.langDetector = 'header'
46
+ return
47
+ }
48
+ }
49
+ req.lang = lang ?? defLang
50
+ }
51
+
52
+ async function attachI18N (detector = [], req, reply) {
53
+ if (!this.app.bajoI18N) return
54
+ const { get } = this.app.bajo.lib._
55
+ detect.call(this, detector, req, reply)
56
+ const i18n = this.app.bajoI18N.instance.cloneInstance()
57
+ const defNs = get(req, 'routeOptions.config.ns', 'wakatobi')
58
+ i18n.setDefaultNamespace(defNs)
59
+ await i18n.changeLanguage(req.lang)
60
+ req.i18n = i18n
61
+ }
62
+
63
+ export default attachI18N
@@ -0,0 +1,22 @@
1
+ async function errorHandler (ctx, extHandler) {
2
+ const me = this
3
+ ctx.setErrorHandler(async function (err, req, reply) {
4
+ if (err.redirect) {
5
+ reply.redirect(err.redirect)
6
+ return
7
+ }
8
+ if (err.print) {
9
+ reply.send(err.print)
10
+ return
11
+ }
12
+ if (me.app.bajo.config.log.level === 'trace' && !['notfound', 'redirect'].includes(err.message)) console.error(err)
13
+ if (extHandler) return await extHandler.call(me, ctx, err, req, reply)
14
+ if (err.message === 'notfound' || err.statusCode === 404) {
15
+ reply.code(err.statusCode)
16
+ return
17
+ }
18
+ reply.code(err.statusCode ?? 500)
19
+ })
20
+ }
21
+
22
+ export default errorHandler
@@ -0,0 +1,8 @@
1
+ import compress from '@fastify/compress'
2
+
3
+ async function handleCompress (ctx, options = {}) {
4
+ if (!options) return
5
+ await ctx.register(compress, options)
6
+ }
7
+
8
+ export default handleCompress
@@ -0,0 +1,8 @@
1
+ import cors from '@fastify/cors'
2
+
3
+ async function handleCors (ctx, options = {}) {
4
+ if (!options) return
5
+ await ctx.register(cors, options)
6
+ }
7
+
8
+ export default handleCors
@@ -0,0 +1,8 @@
1
+ import helmet from '@fastify/helmet'
2
+
3
+ async function handleHelmet (ctx, options = {}) {
4
+ if (!options) return
5
+ await ctx.register(helmet, options)
6
+ }
7
+
8
+ export default handleHelmet
@@ -0,0 +1,72 @@
1
+ import multipart from '@fastify/multipart'
2
+ import { promisify } from 'util'
3
+ import { pipeline } from 'stream'
4
+ import path from 'path'
5
+ const pump = promisify(pipeline)
6
+
7
+ async function onFileHandler () {
8
+ const { getPluginDataDir } = this.app.bajo
9
+ const { fs } = this.app.bajo.lib
10
+ const dir = `${getPluginDataDir(this.name)}/upload`
11
+ return async function (part) {
12
+ // 'this' is the fastify context here
13
+ const filePath = `${dir}/${this.id}/${part.fieldname}@${part.filename}`
14
+ await fs.ensureDir(path.dirname(filePath))
15
+ await pump(part.file, fs.createWriteStream(filePath))
16
+ }
17
+ }
18
+
19
+ async function handleMultipartBody (ctx, options = {}) {
20
+ const { defaultsDeep, importPkg, isSet } = this.app.bajo
21
+ const { isArray, map, trim, isPlainObject, isEmpty } = this.app.bajo.lib._
22
+ const parseVar = await importPkg('dotenv-parse-variables')
23
+ const opts = defaultsDeep(options, this.config.multipart)
24
+ const onFile = await onFileHandler.call(this)
25
+ opts.onFile = onFile
26
+ await ctx.register(multipart, opts)
27
+
28
+ function normalizeValue (value) {
29
+ if (!isSet(value)) return
30
+ if (value === 'null') value = null
31
+ else if (value === 'undefined') value = undefined
32
+ else {
33
+ const val = trim(value)
34
+ if (['{', '['].includes(val[0])) {
35
+ try {
36
+ const parsed = JSON.parse(val)
37
+ if (isPlainObject(parsed) || isArray(parsed)) value = parsed
38
+ } catch (err) {
39
+ value = val
40
+ }
41
+ } else value = parseVar({ item: value }).item
42
+ }
43
+ return value
44
+ }
45
+
46
+ ctx.addHook('preValidation', async function (req, reply) {
47
+ if (req.isMultipart() && opts.attachFieldsToBody === true) {
48
+ const body = Object.fromEntries(
49
+ Object.keys(req.body || {}).map((key) => {
50
+ let item = req.body[key]
51
+ let value
52
+ if (key.endsWith('[]') && !isArray(item)) item = [item]
53
+ if (isArray(item)) {
54
+ value = map(item, i => normalizeValue(i.value))
55
+ } else {
56
+ value = normalizeValue(item.value)
57
+ }
58
+ key = key.replace('[]', '')
59
+ return [key, value]
60
+ })
61
+ )
62
+ const newBody = {}
63
+ for (const k in body) {
64
+ if (isEmpty(k)) continue
65
+ newBody[k] = body[k]
66
+ }
67
+ req.body = newBody
68
+ }
69
+ })
70
+ }
71
+
72
+ export default handleMultipartBody
@@ -0,0 +1,9 @@
1
+ import rateLimit from '@fastify/rate-limit'
2
+
3
+ async function handleRateLimit (ctx, options = {}) {
4
+ const { cloneDeep } = this.app.bajo.lib._
5
+ if (!options) return
6
+ await ctx.register(rateLimit, cloneDeep(options))
7
+ }
8
+
9
+ export default handleRateLimit
@@ -0,0 +1,28 @@
1
+ function santizeMethods (methods = '*') {
2
+ if (['*', 'all'].includes(methods)) methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
3
+ else methods = methods.split(',').map(s => s.trim())
4
+ return methods
5
+ }
6
+
7
+ async function isRouteDisabled (url, method, matchers = []) {
8
+ const { outmatch } = this.app.bajo
9
+ const { intersection, cloneDeep } = this.app.bajo.lib._
10
+ const items = []
11
+ for (const m of cloneDeep(matchers)) {
12
+ m.path = this.app.wakatobi.routePath(m.path)
13
+ m.methods = santizeMethods(m.methods)
14
+ items.push(m)
15
+ }
16
+ const matcher = items.find(i => {
17
+ const isMatch = outmatch(i.path)
18
+ return isMatch(url)
19
+ })
20
+ if (!matcher) return false
21
+ if (Array.isArray(method)) {
22
+ const result = intersection(method, matcher.methods)
23
+ return result.length > 0
24
+ }
25
+ return matcher.methods.includes(method)
26
+ }
27
+
28
+ export default isRouteDisabled
@@ -0,0 +1,12 @@
1
+ async function reroutedPath (path, mapper = {}) {
2
+ const { routePath } = this.app.wakatobi
3
+ let result
4
+ for (let k in mapper) {
5
+ const v = routePath(mapper[k])
6
+ k = routePath(k)
7
+ if (k === path) result = v
8
+ }
9
+ return result
10
+ }
11
+
12
+ export default reroutedPath
@@ -0,0 +1,13 @@
1
+ async function routeHook (ns) {
2
+ const ctx = this.app[ns].instance
3
+ const { runHook } = this.app.bajo
4
+ const { hookTypes } = this.app.wakatobi
5
+ for (const hook of hookTypes) {
6
+ ctx.addHook(hook, async function (...args) {
7
+ const context = this // encapsulated fastify scope
8
+ await runHook(`${ns}:${hook}`, context, ...args)
9
+ })
10
+ }
11
+ }
12
+
13
+ export default routeHook
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "waibu",
3
+ "version": "1.0.0",
4
+ "description": "Web Framework for Bajo",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "type": "module",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/ardhi/waibu.git"
13
+ },
14
+ "keywords": [
15
+ "web",
16
+ "webserver",
17
+ "bajo",
18
+ "framework",
19
+ "fastify",
20
+ "modular"
21
+ ],
22
+ "author": "Ardhi Lukianto <ardhi@lukianto.com>",
23
+ "license": "MIT",
24
+ "bugs": {
25
+ "url": "https://github.com/ardhi/waibu/issues"
26
+ },
27
+ "homepage": "https://github.com/ardhi/waibu#readme",
28
+ "dependencies": {
29
+ "@fastify/accepts": "^4.3.0",
30
+ "@fastify/compress": "^7.0.3",
31
+ "@fastify/cookie": "^9.3.1",
32
+ "@fastify/cors": "^9.0.1",
33
+ "@fastify/flash": "^5.2.0",
34
+ "@fastify/formbody": "^7.4.0",
35
+ "@fastify/helmet": "^11.1.1",
36
+ "@fastify/multipart": "^8.3.0",
37
+ "@fastify/rate-limit": "^9.1.0",
38
+ "@fastify/reply-from": "^9.8.0",
39
+ "@fastify/sensible": "^5.6.0",
40
+ "@fastify/session": "^10.9.0",
41
+ "@fastify/static": "github:ardhi/fastify-static#v7.x",
42
+ "@fastify/under-pressure": "^8.5.1",
43
+ "fastify": "^4.28.1",
44
+ "fastify-no-icon": "^6.0.0"
45
+ }
46
+ }