fusion-framework 1.2.6 → 2.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.
package/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const path = require('path')
2
2
  const fs = require('fs')
3
+ const { spawn } = require('child_process')
3
4
  const { platform, arch } = process
4
5
 
5
6
  function napiTriple() {
@@ -134,7 +135,7 @@ header.fingerprint = () =>
134
135
  : {
135
136
  'X-Powered-By': 'Fusion Framework',
136
137
  'X-Framework': 'Fusion',
137
- ['X-Fusion-Version']: '1.2.6',
138
+ ['X-Fusion-Version']: '2.0.0',
138
139
  }
139
140
 
140
141
  function isThenable(value) {
@@ -168,6 +169,221 @@ function frameworkHeaders() {
168
169
  }
169
170
  }
170
171
 
172
+ function getHeader(request, name) {
173
+ const headers = request.headers || {}
174
+ const target = String(name).toLowerCase()
175
+ for (const [key, value] of Object.entries(headers)) {
176
+ if (String(key).toLowerCase() === target) return String(value)
177
+ }
178
+ return null
179
+ }
180
+
181
+ function headerMiddleware(extra) {
182
+ return async (request, callNext) => {
183
+ const result = await awaitMaybe(callNext(request))
184
+ return mergeResponseHeaders(result, extra)
185
+ }
186
+ }
187
+
188
+ function securityHeaders(options = {}) {
189
+ const extra = {
190
+ 'X-Content-Type-Options': options.contentTypeOptions ?? 'nosniff',
191
+ 'X-Frame-Options': options.frameOptions ?? 'DENY',
192
+ 'Referrer-Policy': options.referrerPolicy ?? 'strict-origin-when-cross-origin',
193
+ 'Permissions-Policy':
194
+ options.permissionsPolicy ?? 'camera=(), microphone=(), geolocation=(), payment=()',
195
+ 'Cross-Origin-Opener-Policy': options.coop ?? 'same-origin',
196
+ 'Cross-Origin-Resource-Policy': options.corp ?? 'same-origin',
197
+ }
198
+ if (options.csp) extra['Content-Security-Policy'] = String(options.csp)
199
+ if (options.hsts) extra['Strict-Transport-Security'] = String(options.hsts)
200
+ return headerMiddleware(extra)
201
+ }
202
+
203
+ function cacheHeaders(options = {}) {
204
+ return headerMiddleware({
205
+ 'Cache-Control': options.default ?? options.value ?? 'no-store',
206
+ })
207
+ }
208
+
209
+ function requestId(options = {}) {
210
+ const headerName = options.header ?? 'X-Request-Id'
211
+ const incoming = options.incoming !== false
212
+ return async (request, callNext) => {
213
+ const state = ensureState(request)
214
+ let rid = incoming ? getHeader(request, headerName) : null
215
+ if (!rid) {
216
+ rid =
217
+ typeof crypto !== 'undefined' && crypto.randomUUID
218
+ ? crypto.randomUUID()
219
+ : `${Date.now()}-${Math.random().toString(16).slice(2)}`
220
+ }
221
+ state.request_id = rid
222
+ const result = await awaitMaybe(callNext(request))
223
+ return mergeResponseHeaders(result, { [headerName]: rid })
224
+ }
225
+ }
226
+
227
+ function cors(options = {}) {
228
+ const origins = Array.isArray(options.allowOrigins)
229
+ ? options.allowOrigins.map(String)
230
+ : [String(options.allowOrigins ?? '*')]
231
+ const methods = (
232
+ options.allowMethods ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']
233
+ ).map((m) => String(m).toUpperCase())
234
+ const allowHeaders = (
235
+ options.allowHeaders ?? ['Authorization', 'Content-Type', 'Accept', 'Origin', 'X-Request-Id']
236
+ ).map(String)
237
+ const exposeHeaders = (options.exposeHeaders ?? ['X-Request-Id']).map(String)
238
+ const allowCredentials = !!options.allowCredentials
239
+ const maxAge = Number(options.maxAge ?? 600)
240
+ const allowAll = origins.includes('*')
241
+
242
+ function corsHeaders(origin) {
243
+ let chosen = '*'
244
+ if (!allowAll) {
245
+ if (origin && origins.includes(origin)) chosen = origin
246
+ else if (origins.length) chosen = origins[0]
247
+ }
248
+ const out = {
249
+ 'Access-Control-Allow-Origin': chosen,
250
+ 'Access-Control-Allow-Methods': methods.join(', '),
251
+ 'Access-Control-Allow-Headers': allowHeaders.join(', '),
252
+ 'Access-Control-Expose-Headers': exposeHeaders.join(', '),
253
+ 'Access-Control-Max-Age': String(maxAge),
254
+ Vary: 'Origin',
255
+ }
256
+ if (allowCredentials && chosen !== '*') out['Access-Control-Allow-Credentials'] = 'true'
257
+ return out
258
+ }
259
+
260
+ return async (request, callNext) => {
261
+ const origin = getHeader(request, 'Origin')
262
+ const extra = corsHeaders(origin)
263
+ if (String(request.method || 'GET').toUpperCase() === 'OPTIONS') {
264
+ return { status: 204, body: '', headers: extra }
265
+ }
266
+ const result = await awaitMaybe(callNext(request))
267
+ return mergeResponseHeaders(result, extra)
268
+ }
269
+ }
270
+
271
+ const STATIC_MIME_TYPES = {
272
+ '.css': 'text/css; charset=utf-8',
273
+ '.gif': 'image/gif',
274
+ '.htm': 'text/html; charset=utf-8',
275
+ '.html': 'text/html; charset=utf-8',
276
+ '.ico': 'image/x-icon',
277
+ '.jpeg': 'image/jpeg',
278
+ '.jpg': 'image/jpeg',
279
+ '.js': 'text/javascript; charset=utf-8',
280
+ '.json': 'application/json',
281
+ '.map': 'application/json',
282
+ '.png': 'image/png',
283
+ '.svg': 'image/svg+xml',
284
+ '.txt': 'text/plain; charset=utf-8',
285
+ '.webp': 'image/webp',
286
+ '.woff': 'font/woff',
287
+ '.woff2': 'font/woff2',
288
+ }
289
+
290
+ /** Guess Content-Type from a file path extension. */
291
+ function guessStaticContentType(filePath) {
292
+ const ext = path.extname(String(filePath)).toLowerCase()
293
+ return STATIC_MIME_TYPES[ext] || 'application/octet-stream'
294
+ }
295
+
296
+ /**
297
+ * Serve files from `root` for URLs under `prefix` (WhiteNoise-style).
298
+ *
299
+ * - root: folder on disk (e.g. 'static')
300
+ * - prefix: URL prefix (e.g. '/static' → static/logo.png at /static/logo.png)
301
+ *
302
+ * Files are also mounted as real GET/HEAD routes on FusionApp.mount()/listen().
303
+ */
304
+ function staticFiles(options = {}) {
305
+ const rootDir = path.resolve(String(options.root ?? 'static'))
306
+ const rawPrefix = String(options.prefix ?? '/static').trim()
307
+ const normalized = rawPrefix.replace(/\/+$/, '') === '' ? '/' : `/${rawPrefix.replace(/^\/+|\/+$/g, '')}`
308
+ const maxAge = options.maxAge === undefined ? 3600 : options.maxAge
309
+ const allowFallthrough =
310
+ options.fallthrough === undefined ? normalized === '/' : !!options.fallthrough
311
+ const cfg = { root: rootDir, prefix: normalized, maxAge, fallthrough: allowFallthrough }
312
+
313
+ const middleware = (request, callNext) => serveStaticOrNext(cfg, request, callNext)
314
+ middleware.__fusionStatic = cfg
315
+ return middleware
316
+ }
317
+
318
+ /** Build a 200 file response envelope. */
319
+ function staticFileResponse(filePath, method, maxAge) {
320
+ const size = fs.statSync(filePath).size
321
+ const headers = {
322
+ 'content-type': guessStaticContentType(filePath),
323
+ 'content-length': String(size),
324
+ }
325
+ if (maxAge !== null && maxAge !== undefined) {
326
+ headers['cache-control'] = `public, max-age=${Number(maxAge)}`
327
+ }
328
+ const body = String(method).toUpperCase() === 'HEAD' ? Buffer.alloc(0) : fs.readFileSync(filePath)
329
+ return { status: 200, body, headers }
330
+ }
331
+
332
+ /** Try to serve a static file; otherwise callNext. */
333
+ function serveStaticOrNext(cfg, request, callNext) {
334
+ const method = String(request.method || 'GET').toUpperCase()
335
+ if (method !== 'GET' && method !== 'HEAD') return callNext(request)
336
+
337
+ const reqPath = String(request.path || '/')
338
+ const normalized = cfg.prefix
339
+ let relative = ''
340
+ if (normalized === '/') {
341
+ relative = reqPath.replace(/^\/+/, '')
342
+ if (!relative || relative.endsWith('/')) return callNext(request)
343
+ } else {
344
+ if (!(reqPath === normalized || reqPath.startsWith(`${normalized}/`))) {
345
+ return callNext(request)
346
+ }
347
+ relative = reqPath.slice(normalized.length).replace(/^\/+/, '')
348
+ if (!relative) return callNext(request)
349
+ }
350
+
351
+ const candidate = path.resolve(cfg.root, relative)
352
+ const relToRoot = path.relative(cfg.root, candidate)
353
+ if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot)) {
354
+ return { status: 403, body: { detail: 'Forbidden' } }
355
+ }
356
+ if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
357
+ if (cfg.fallthrough) return callNext(request)
358
+ return { status: 404, body: { detail: 'Not found' } }
359
+ }
360
+ return staticFileResponse(candidate, method, cfg.maxAge)
361
+ }
362
+
363
+ /** Register GET/HEAD routes for files under each staticFiles() mount. */
364
+ function mountStaticFiles(engine, middlewares) {
365
+ for (const mw of middlewares || []) {
366
+ const cfg = mw && mw.__fusionStatic
367
+ if (!cfg || !fs.existsSync(cfg.root) || !fs.statSync(cfg.root).isDirectory()) continue
368
+ const walk = (dir) => {
369
+ for (const name of fs.readdirSync(dir)) {
370
+ const full = path.join(dir, name)
371
+ const st = fs.statSync(full)
372
+ if (st.isDirectory()) {
373
+ walk(full)
374
+ continue
375
+ }
376
+ if (!st.isFile()) continue
377
+ const rel = path.relative(cfg.root, full).split(path.sep).join('/')
378
+ const url = cfg.prefix === '/' ? `/${rel}` : `${cfg.prefix}/${rel}`
379
+ engine.route('GET', url, () => staticFileResponse(full, 'GET', cfg.maxAge))
380
+ engine.route('HEAD', url, () => staticFileResponse(full, 'HEAD', cfg.maxAge))
381
+ }
382
+ }
383
+ walk(cfg.root)
384
+ }
385
+ }
386
+
171
387
  class FusionBaseApi {
172
388
  constructor(request) {
173
389
  this.request = request && typeof request === 'object' ? request : emptyRequest()
@@ -228,6 +444,213 @@ class FusionBaseApi {
228
444
  const body = paginatedBody(items, total, p)
229
445
  return this.response(body, status, headers || {})
230
446
  }
447
+
448
+ wantsJson() {
449
+ let accept = null
450
+ for (const [key, value] of Object.entries(this.headers || {})) {
451
+ if (key.toLowerCase() === 'accept') {
452
+ accept = String(value)
453
+ break
454
+ }
455
+ }
456
+ const format = this.query?.format != null ? String(this.query.format) : null
457
+ return typeof native.prefersJsonJs === 'function'
458
+ ? native.prefersJsonJs(accept, format)
459
+ : prefersJsonFallback(accept, format)
460
+ }
461
+ }
462
+
463
+ function prefersJsonFallback(accept, formatQuery) {
464
+ if (formatQuery && String(formatQuery).toLowerCase() === 'json') return true
465
+ const value = String(accept || '').trim().toLowerCase()
466
+ if (!value) return false
467
+ let bestJson = -1
468
+ let bestHtml = -1
469
+ for (const part of value.split(',')) {
470
+ const tokens = part.trim().split(';').map((t) => t.trim())
471
+ const media = tokens[0] || ''
472
+ let q = 1
473
+ for (const token of tokens.slice(1)) {
474
+ if (token.startsWith('q=')) {
475
+ const parsed = Number.parseFloat(token.slice(2))
476
+ if (!Number.isNaN(parsed)) q = parsed
477
+ }
478
+ }
479
+ if (media === 'application/json' || media === 'text/json') bestJson = Math.max(bestJson, q)
480
+ else if (media === 'text/html' || media === 'application/xhtml+xml') bestHtml = Math.max(bestHtml, q)
481
+ }
482
+ return bestJson > 0 && bestJson >= bestHtml
483
+ }
484
+
485
+ function parseFormBody(body, contentType) {
486
+ const raw = body == null ? '' : String(body)
487
+ const ct = String(contentType || '').toLowerCase()
488
+ if (ct.includes('application/json') || (raw.trim().startsWith('{') && !ct.includes('urlencoded'))) {
489
+ try {
490
+ const data = raw.trim() ? JSON.parse(raw) : {}
491
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
492
+ const out = {}
493
+ for (const [k, v] of Object.entries(data)) out[k] = v == null ? '' : String(v)
494
+ return out
495
+ }
496
+ } catch {
497
+ return {}
498
+ }
499
+ return {}
500
+ }
501
+ const params = new URLSearchParams(raw)
502
+ const out = {}
503
+ for (const key of params.keys()) {
504
+ out[key] = params.get(key) ?? ''
505
+ }
506
+ return out
507
+ }
508
+
509
+ class FusionBaseTemplate extends FusionBaseApi {
510
+ static __fusion_template__ = true
511
+ static template = ''
512
+ static templateAddress = ''
513
+ static templatesDir = ''
514
+
515
+ /**
516
+ * Template variables (not an HTTP verb). May return a Promise.
517
+ * get() renders this as HTML; post() should use form / ok / fail.
518
+ */
519
+ context() {
520
+ return {}
521
+ }
522
+
523
+ /** Parsed POST body (urlencoded or JSON) as flat string fields. */
524
+ get form() {
525
+ let contentType = null
526
+ for (const [key, value] of Object.entries(this.headers || {})) {
527
+ if (String(key).toLowerCase() === 'content-type') {
528
+ contentType = String(value)
529
+ break
530
+ }
531
+ }
532
+ return parseFormBody(this.body, contentType)
533
+ }
534
+
535
+ get() {
536
+ const raw = this.context()
537
+ if (raw && typeof raw.then === 'function') {
538
+ return this._getAsync(raw)
539
+ }
540
+ return this._finishGet(raw)
541
+ }
542
+
543
+ async _getAsync(raw) {
544
+ const ctx = await raw
545
+ return this._finishGet(ctx)
546
+ }
547
+
548
+ _finishGet(ctx) {
549
+ const data = { ...(ctx || {}) }
550
+ if (this.wantsJson()) return data
551
+ return this._htmlResponse(data)
552
+ }
553
+
554
+ /**
555
+ * Validation failure — JSON for SPA fetch, else same template with errors.
556
+ * fail({ phone: 'required' }, { message: 'خطا', ...formFields })
557
+ */
558
+ fail(errors = {}, extras = {}) {
559
+ const bag = typeof extras === 'string' ? { message: extras } : { ...(extras || {}) }
560
+ const message = bag.message != null ? String(bag.message) : 'Validation failed'
561
+ delete bag.message
562
+ const err = {}
563
+ for (const [k, v] of Object.entries(errors || {})) err[k] = String(v)
564
+ const flat = {}
565
+ for (const [k, v] of Object.entries(bag)) flat[k] = v == null ? '' : String(v)
566
+
567
+ if (this.wantsJson()) {
568
+ return this.response({ ok: false, message, errors: err, fields: flat }, 400)
569
+ }
570
+ return this._formHtmlResult({ ok: false, message, errors: err, fields: flat, status: 400 })
571
+ }
572
+
573
+ /** Success — JSON for SPA fetch, else same template with ok=true. */
574
+ ok(extras = {}) {
575
+ const bag = typeof extras === 'string' ? { message: extras } : { ...(extras || {}) }
576
+ const message = bag.message != null ? String(bag.message) : 'OK'
577
+ delete bag.message
578
+ const flat = {}
579
+ for (const [k, v] of Object.entries(bag)) flat[k] = v == null ? '' : String(v)
580
+
581
+ if (this.wantsJson()) {
582
+ return this.response({ ok: true, message, errors: {}, fields: flat }, 200)
583
+ }
584
+ return this._formHtmlResult({ ok: true, message, errors: {}, fields: flat, status: 200 })
585
+ }
586
+
587
+ _formHtmlResult({ ok, message, errors, fields, status }) {
588
+ const raw = this.context()
589
+ if (raw && typeof raw.then === 'function') {
590
+ return this._formHtmlResultAsync(raw, { ok, message, errors, fields, status })
591
+ }
592
+ return this._finishFormHtml(raw, { ok, message, errors, fields, status })
593
+ }
594
+
595
+ async _formHtmlResultAsync(raw, opts) {
596
+ const ctx = await raw
597
+ return this._finishFormHtml(ctx, opts)
598
+ }
599
+
600
+ _finishFormHtml(ctx, { ok, message, errors, fields, status }) {
601
+ const data = { ...(ctx || {}), ...fields, ok, message, errors: { ...errors }, fields: { ...fields } }
602
+ return this._htmlResponse(data, { status })
603
+ }
604
+
605
+ templateName() {
606
+ const name = this.constructor.template || this.constructor.templateAddress
607
+ if (!name) {
608
+ throw new Error(`${this.constructor.name} must set static template or templateAddress`)
609
+ }
610
+ return name
611
+ }
612
+
613
+ templatesRoot() {
614
+ if (this.constructor.templatesDir) return this.constructor.templatesDir
615
+ return String(settings.get('templates.dir', 'templates'))
616
+ }
617
+
618
+ render({
619
+ status = 200,
620
+ headers = {},
621
+ context = null,
622
+ templateName = null,
623
+ } = {}) {
624
+ const raw = this.context()
625
+ if (raw && typeof raw.then === 'function') {
626
+ return this._renderAsync(raw, { status, headers, context, templateName })
627
+ }
628
+ const ctx = { ...(raw || {}), ...(context || {}) }
629
+ return this._htmlResponse(ctx, { status, headers, templateName })
630
+ }
631
+
632
+ async _renderAsync(raw, { status = 200, headers = {}, context = null, templateName = null } = {}) {
633
+ const base = await raw
634
+ const ctx = { ...(base || {}), ...(context || {}) }
635
+ return this._htmlResponse(ctx, { status, headers, templateName })
636
+ }
637
+
638
+ _htmlResponse(ctx, { status = 200, headers = {}, templateName = null } = {}) {
639
+ const html = renderTemplate(
640
+ templateName || this.templateName(),
641
+ ctx,
642
+ this.templatesRoot(),
643
+ )
644
+ return this.response(html, status, {
645
+ 'content-type': 'text/html; charset=utf-8',
646
+ ...headers,
647
+ })
648
+ }
649
+ }
650
+
651
+ function renderTemplate(templateName, context = {}, templatesRoot = null) {
652
+ const root = templatesRoot ?? String(settings.get('templates.dir', 'templates'))
653
+ return native.renderTemplateJs(templateName, context || {}, root)
231
654
  }
232
655
 
233
656
  function apiResourceName(cls) {
@@ -421,6 +844,17 @@ async function runMiddlewareChain(request, middlewares, handler) {
421
844
  return dispatch(0, request)
422
845
  }
423
846
 
847
+ function requirePermissions(...checks) {
848
+ return (request, callNext) => {
849
+ for (const check of checks) {
850
+ if (!check(request)) {
851
+ return { status: 403, body: { detail: 'Forbidden' } }
852
+ }
853
+ }
854
+ return callNext(request)
855
+ }
856
+ }
857
+
424
858
  function requireRoles(...rolesOrOptions) {
425
859
  let roles = rolesOrOptions
426
860
  let claim = 'roles'
@@ -504,14 +938,9 @@ function router(routePath, options = {}) {
504
938
  ApiClass.__fusion_path_template__ = routePath
505
939
 
506
940
  const routeMiddleware = Array.isArray(options.middleware) ? [...options.middleware] : []
507
- if (Array.isArray(options.roles) && options.roles.length) {
508
- routeMiddleware.push(
509
- requireRoles({
510
- roles: options.roles,
511
- claim: options.roleClaim || 'roles',
512
- stateKey: options.roleStateKey || 'jwt',
513
- }),
514
- )
941
+ const permissionChecks = Array.isArray(options.permissions) ? options.permissions : []
942
+ if (permissionChecks.length) {
943
+ routeMiddleware.push(requirePermissions(...permissionChecks))
515
944
  }
516
945
 
517
946
  const classSwagger = {
@@ -528,6 +957,7 @@ function router(routePath, options = {}) {
528
957
  middleware: routeMiddleware,
529
958
  swagger: classSwagger,
530
959
  version_prefix: v,
960
+ requiresPermissions: permissionChecks.length > 0,
531
961
  slots: collectRouteSlots(ApiClass, resolved, classSwagger),
532
962
  })
533
963
  return ApiClass
@@ -653,7 +1083,7 @@ function readSwaggerSettings() {
653
1083
  showCommonExtensions: false,
654
1084
  syntaxHighlight: { activated: true, theme: 'agate' },
655
1085
  withCredentials: false,
656
- validatorUrl: 'https://validator.swagger.io/validator',
1086
+ validatorUrl: null,
657
1087
  ...asObject(settings.get('swagger.ui', {})),
658
1088
  }
659
1089
  if (Object.prototype.hasOwnProperty.call(authRaw, 'persistAuthorization')) {
@@ -682,6 +1112,102 @@ function readSwaggerSettings() {
682
1112
 
683
1113
  const UNVERSIONED_SWAGGER_NAME = 'default'
684
1114
 
1115
+ const SWAGGER_ASSETS_DIR = path.join(__dirname, 'static', 'swagger-ui')
1116
+ const SWAGGER_ASSET_TYPES = {
1117
+ 'swagger-ui-bundle.js': 'application/javascript; charset=utf-8',
1118
+ 'swagger-ui-standalone-preset.js': 'application/javascript; charset=utf-8',
1119
+ 'swagger-ui.css': 'text/css; charset=utf-8',
1120
+ }
1121
+
1122
+ function loadSwaggerAssets() {
1123
+ const out = {}
1124
+ for (const [name, contentType] of Object.entries(SWAGGER_ASSET_TYPES)) {
1125
+ const filePath = path.join(SWAGGER_ASSETS_DIR, name)
1126
+ if (!fs.existsSync(filePath)) continue
1127
+ out[name] = { contentType, body: fs.readFileSync(filePath, 'utf8') }
1128
+ }
1129
+ return out
1130
+ }
1131
+
1132
+ const SWAGGER_ASSETS = loadSwaggerAssets()
1133
+
1134
+ function swaggerAssetUrl(prefix, name) {
1135
+ return `${prefix}/assets/${name}`
1136
+ }
1137
+
1138
+
1139
+ /** Normalize monitor.path from settings (default /__fusion/monitor). */
1140
+ function normalizeMonitorPath(raw) {
1141
+ let path = String(raw == null || raw === '' ? '/__fusion/monitor' : raw).trim() || '/__fusion/monitor'
1142
+ if (!path.startsWith('/')) path = `/${path}`
1143
+ return path.replace(/\/+$/, '') || '/__fusion/monitor'
1144
+ }
1145
+
1146
+ function resolveMonitorEnabled(settingsInstance) {
1147
+ const s = settingsInstance || settings
1148
+ const top = s.get('monitor.enabled', null)
1149
+ if (top !== null && top !== undefined) {
1150
+ return truthyEnabled(top, false)
1151
+ }
1152
+ return truthyEnabled(s.get('cache.monitor.enabled', false), false)
1153
+ }
1154
+
1155
+ function resolveMonitorPath(settingsInstance) {
1156
+ const s = settingsInstance || settings
1157
+ const top = s.get('monitor.path', null)
1158
+ if (top !== null && top !== undefined && String(top).trim() !== '') {
1159
+ return normalizeMonitorPath(top)
1160
+ }
1161
+ return normalizeMonitorPath(s.get('cache.monitor.path', '/__fusion/monitor'))
1162
+ }
1163
+
1164
+ /**
1165
+ * Built-in Fusion monitor (cache + background tasks).
1166
+ * When monitor.enabled is false, no routes are registered.
1167
+ */
1168
+ function mountMonitor(engine, settingsInstance) {
1169
+ const s = settingsInstance || settings
1170
+ if (!resolveMonitorEnabled(s)) {
1171
+ return false
1172
+ }
1173
+ cache.configure(s)
1174
+ const path = resolveMonitorPath(s)
1175
+
1176
+ class MonitorPanel extends FusionBaseTemplate {
1177
+ static template = 'fusion/monitor.html'
1178
+ context() {
1179
+ return cache.panelContext()
1180
+ }
1181
+ }
1182
+
1183
+ const htmlHandler = (errOrRequest, maybeRequest) => {
1184
+ const request = nativeRequestArg(errOrRequest, maybeRequest)
1185
+ return new MonitorPanel(request || emptyRequest()).get()
1186
+ }
1187
+ const jsonHandler = () => cache.snapshot()
1188
+
1189
+ engine.route('GET', path, htmlHandler)
1190
+ if (path !== '/') {
1191
+ engine.route('GET', `${path}/`, htmlHandler)
1192
+ }
1193
+ engine.route('GET', `${path}/json`, jsonHandler)
1194
+ return true
1195
+ }
1196
+
1197
+ /** @deprecated Use mountMonitor */
1198
+ const mountCacheMonitor = mountMonitor
1199
+
1200
+ function mountSwaggerAssets(engine, prefix) {
1201
+ const assetsPrefix = `${prefix}/assets`
1202
+ for (const [name, { contentType, body }] of Object.entries(SWAGGER_ASSETS)) {
1203
+ engine.route('GET', `${assetsPrefix}/${name}`, () => ({
1204
+ status: 200,
1205
+ body,
1206
+ headers: { 'content-type': contentType },
1207
+ }))
1208
+ }
1209
+ }
1210
+
685
1211
  function normalizeVersionLabel(value) {
686
1212
  return String(value || '')
687
1213
  .trim()
@@ -702,6 +1228,40 @@ function collectRouteVersions() {
702
1228
  return { versions, hasUnversioned }
703
1229
  }
704
1230
 
1231
+ function clearRouteRegistry() {
1232
+ registry.length = 0
1233
+ }
1234
+
1235
+ function testSwaggerConfig() {
1236
+ return {
1237
+ path: '/swagger',
1238
+ info: { title: 'fusion-framework', version: '1.0.0' },
1239
+ servers: [],
1240
+ auth: { schemes: {}, global: [], oauth: {} },
1241
+ navbar: {
1242
+ enabled: true,
1243
+ showUrlInput: false,
1244
+ showUrlInputSet: true,
1245
+ urlsSet: false,
1246
+ urls: [],
1247
+ },
1248
+ ui: {},
1249
+ pageTitle: 'Fusion API Docs',
1250
+ }
1251
+ }
1252
+
1253
+ function openapiSpec(version = null) {
1254
+ return buildOpenApi(testSwaggerConfig(), version)
1255
+ }
1256
+
1257
+ function routeVersions() {
1258
+ return collectRouteVersions().versions
1259
+ }
1260
+
1261
+ function hasUnversionedRoutes() {
1262
+ return collectRouteVersions().hasUnversioned
1263
+ }
1264
+
705
1265
  function swaggerVersionUrls(prefix) {
706
1266
  const { versions, hasUnversioned } = collectRouteVersions()
707
1267
  const urls = versions.map((label) => ({
@@ -748,6 +1308,17 @@ function applySwaggerOpenApi(openapi, swagger) {
748
1308
  return openapi
749
1309
  }
750
1310
 
1311
+ const OPENAPI_PERMISSIONS_SCHEME = 'FusionPermissions'
1312
+
1313
+ function isTemplateClass(ApiClass) {
1314
+ let current = ApiClass
1315
+ while (current && current !== Function.prototype) {
1316
+ if (current === FusionBaseTemplate || current.__fusion_template__) return true
1317
+ current = Object.getPrototypeOf(current)
1318
+ }
1319
+ return false
1320
+ }
1321
+
751
1322
  function fillOpenApiPaths(openapi, versionFilter = null) {
752
1323
  const parsePathParams = (pattern) => {
753
1324
  return String(pattern)
@@ -756,9 +1327,13 @@ function fillOpenApiPaths(openapi, versionFilter = null) {
756
1327
  .map((seg) => seg.slice(1, -1))
757
1328
  }
758
1329
 
1330
+ let anyPermissions = false
1331
+
759
1332
  for (const item of registry) {
760
1333
  if (!routeMatchesVersion(item, versionFilter)) continue
761
- const { ApiClass, swagger: routeSwagger } = item
1334
+ const { ApiClass, swagger: routeSwagger, requiresPermissions } = item
1335
+ if (isTemplateClass(ApiClass)) continue
1336
+ if (requiresPermissions) anyPermissions = true
762
1337
  const slots = item.slots || []
763
1338
 
764
1339
  for (const slot of slots) {
@@ -784,10 +1359,27 @@ function fillOpenApiPaths(openapi, versionFilter = null) {
784
1359
  deprecated: !!routeSwaggerEntry?.deprecated,
785
1360
  operationId: `${ApiClass.name}_${slot.handlerMethod}`,
786
1361
  parameters: params,
787
- responses: { 200: { description: 'OK' } },
1362
+ responses: {
1363
+ 200: { description: 'OK' },
1364
+ ...(requiresPermissions ? { 403: { description: 'Forbidden — permission check failed' } } : {}),
1365
+ },
1366
+ ...(requiresPermissions ? { security: [{ [OPENAPI_PERMISSIONS_SCHEME]: [] }] } : {}),
788
1367
  }
789
1368
  }
790
1369
  }
1370
+
1371
+ if (anyPermissions) {
1372
+ openapi.components = asObject(openapi.components)
1373
+ openapi.components.securitySchemes = {
1374
+ ...asObject(openapi.components.securitySchemes),
1375
+ [OPENAPI_PERMISSIONS_SCHEME]: {
1376
+ type: 'apiKey',
1377
+ in: 'header',
1378
+ name: 'Authorization',
1379
+ description: 'Route requires custom permission checks to pass',
1380
+ },
1381
+ }
1382
+ }
791
1383
  return openapi
792
1384
  }
793
1385
 
@@ -835,6 +1427,8 @@ function swaggerUiHtml(swagger, openapiUrl, primaryName = null) {
835
1427
 
836
1428
  const navbarEnabled = !!swagger.navbar?.enabled
837
1429
  const showUrlInput = swagger.navbar?.showUrlInput !== false
1430
+ const versionUrls = swagger.navbar?.urls?.length > 0
1431
+ const needsStandalone = navbarEnabled || versionUrls
838
1432
  const hideUrlCss =
839
1433
  navbarEnabled && !showUrlInput
840
1434
  ? `<style>
@@ -844,9 +1438,10 @@ function swaggerUiHtml(swagger, openapiUrl, primaryName = null) {
844
1438
  }
845
1439
  </style>`
846
1440
  : ''
847
- const standaloneScript = navbarEnabled
848
- ? `<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"></script>`
849
- : ''
1441
+ const standaloneScript =
1442
+ needsStandalone && SWAGGER_ASSETS['swagger-ui-standalone-preset.js']
1443
+ ? `<script src="${swaggerAssetUrl(swagger.path, 'swagger-ui-standalone-preset.js')}"></script>`
1444
+ : ''
850
1445
 
851
1446
  return `<!doctype html>
852
1447
  <html>
@@ -854,19 +1449,19 @@ function swaggerUiHtml(swagger, openapiUrl, primaryName = null) {
854
1449
  <meta charset="utf-8" />
855
1450
  <meta name="viewport" content="width=device-width, initial-scale=1" />
856
1451
  <title>${title}</title>
857
- <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
1452
+ <link rel="stylesheet" href="${swaggerAssetUrl(swagger.path, 'swagger-ui.css')}" />
858
1453
  ${hideUrlCss}
859
1454
  </head>
860
1455
  <body>
861
1456
  <div id="swagger-ui"></div>
862
- <script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
1457
+ <script src="${swaggerAssetUrl(swagger.path, 'swagger-ui-bundle.js')}"></script>
863
1458
  ${standaloneScript}
864
1459
  <script>
865
1460
  window.onload = function() {
866
1461
  var opts = ${uiJson};
867
1462
  opts.presets = [SwaggerUIBundle.presets.apis];
868
1463
  opts.plugins = [SwaggerUIBundle.plugins.DownloadUrl];
869
- if (${navbarEnabled ? 'true' : 'false'} && typeof SwaggerUIStandalonePreset !== 'undefined') {
1464
+ if (${needsStandalone ? 'true' : 'false'} && typeof SwaggerUIStandalonePreset !== 'undefined') {
870
1465
  opts.presets.push(SwaggerUIStandalonePreset);
871
1466
  opts.layout = 'StandaloneLayout';
872
1467
  } else {
@@ -890,8 +1485,7 @@ class FusionApp {
890
1485
  this.settings = getSettings()
891
1486
  this.engine = new NativeApp()
892
1487
  this.mounted = false
893
- // Default: advertise Fusion to clients / Wappalyzer-style detectors.
894
- this._middleware = [frameworkHeaders()]
1488
+ this._middleware = []
895
1489
  }
896
1490
 
897
1491
  use(middleware) {
@@ -923,9 +1517,12 @@ class FusionApp {
923
1517
  }
924
1518
  }
925
1519
 
1520
+ mountStaticFiles(this.engine, this._middleware)
1521
+
926
1522
  const swagger = readSwaggerSettings()
927
1523
  if (swagger.enabled) {
928
1524
  const prefix = swagger.path
1525
+ mountSwaggerAssets(this.engine, prefix)
929
1526
  const labels = applyVersionNavbar(swagger)
930
1527
  const combined = buildOpenApi(swagger)
931
1528
 
@@ -949,21 +1546,210 @@ class FusionApp {
949
1546
  }
950
1547
  }
951
1548
 
1549
+ mountMonitor(this.engine, settings)
1550
+
952
1551
  this.mounted = true
953
1552
  }
954
1553
 
955
- async listen(host, port) {
956
- this.mount()
1554
+ async listen(host, port, options = {}) {
1555
+ const reloadOpt =
1556
+ options && Object.prototype.hasOwnProperty.call(options, 'reload')
1557
+ ? options.reload
1558
+ : host && typeof host === 'object'
1559
+ ? host.reload
1560
+ : undefined
1561
+ // Support listen({ host, port, reload }) as well as listen(host, port, { reload })
1562
+ let h = host
1563
+ let p = port
1564
+ let reloadArg = reloadOpt
1565
+ let watchDirs = options?.watchDirs
1566
+ if (host && typeof host === 'object' && !Array.isArray(host)) {
1567
+ h = host.host
1568
+ p = host.port
1569
+ reloadArg = host.reload
1570
+ watchDirs = host.watchDirs
1571
+ }
1572
+
957
1573
  const snapshot = getSettings()
958
- const h = host ?? snapshot.host
959
- const p = port ?? snapshot.port
960
- if (snapshot.debug) {
961
- console.log(`fusion listening on http://${h}:${p}`)
1574
+ // getSettings() returns a plain {host,port,debug,env}; reload lives on the Settings handle.
1575
+ const settingsReload = Boolean(
1576
+ typeof snapshot.get === 'function'
1577
+ ? snapshot.get('reload', false)
1578
+ : settings.get('reload', false),
1579
+ )
1580
+ const shouldReload =
1581
+ reloadArg === undefined || reloadArg === null ? settingsReload : Boolean(reloadArg)
1582
+
1583
+ if (shouldReload && process.env.FUSION_RELOAD_CHILD !== '1') {
1584
+ await runWithReloader({ watchDirs })
1585
+ return
1586
+ }
1587
+
1588
+ this.mount()
1589
+ h = h ?? snapshot.host
1590
+ p = p ?? snapshot.port
1591
+ if (snapshot.debug || shouldReload) {
1592
+ const mode = shouldReload ? ' (reload)' : ''
1593
+ console.log(`fusion listening on http://${h}:${p}${mode}`)
962
1594
  }
963
1595
  await this.engine.listen(h, Number(p))
964
1596
  }
965
1597
  }
966
1598
 
1599
+ const RELOAD_SKIP_DIRS = new Set([
1600
+ '.git',
1601
+ '.hg',
1602
+ 'node_modules',
1603
+ 'target',
1604
+ '.venv',
1605
+ 'venv',
1606
+ '__pycache__',
1607
+ 'bin',
1608
+ 'obj',
1609
+ 'dist',
1610
+ 'build',
1611
+ ])
1612
+
1613
+ const RELOAD_EXTENSIONS = new Set([
1614
+ '.js',
1615
+ '.mjs',
1616
+ '.cjs',
1617
+ '.ts',
1618
+ '.json',
1619
+ '.html',
1620
+ '.tera',
1621
+ '.py',
1622
+ '.cs',
1623
+ ])
1624
+
1625
+ function collectWatchedFiles(roots) {
1626
+ const files = []
1627
+ const walk = (dir) => {
1628
+ let entries
1629
+ try {
1630
+ entries = fs.readdirSync(dir, { withFileTypes: true })
1631
+ } catch {
1632
+ return
1633
+ }
1634
+ for (const entry of entries) {
1635
+ if (entry.name.startsWith('.') && entry.name !== '.') continue
1636
+ const full = path.join(dir, entry.name)
1637
+ if (entry.isDirectory()) {
1638
+ if (RELOAD_SKIP_DIRS.has(entry.name)) continue
1639
+ walk(full)
1640
+ } else if (RELOAD_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
1641
+ files.push(full)
1642
+ }
1643
+ }
1644
+ }
1645
+ for (const root of roots) {
1646
+ const resolved = path.resolve(root)
1647
+ try {
1648
+ const st = fs.statSync(resolved)
1649
+ if (st.isFile()) files.push(resolved)
1650
+ else if (st.isDirectory()) walk(resolved)
1651
+ } catch {
1652
+ /* missing root */
1653
+ }
1654
+ }
1655
+ return files
1656
+ }
1657
+
1658
+ function snapshotMtimes(files) {
1659
+ const map = new Map()
1660
+ for (const file of files) {
1661
+ try {
1662
+ map.set(file, fs.statSync(file).mtimeMs)
1663
+ } catch {
1664
+ /* ignore */
1665
+ }
1666
+ }
1667
+ return map
1668
+ }
1669
+
1670
+ async function runWithReloader({ watchDirs } = {}) {
1671
+ const roots = watchDirs?.length ? watchDirs : [process.cwd()]
1672
+ console.log(`fusion: reload enabled (watching ${roots.join(', ')})`)
1673
+
1674
+ let child = null
1675
+ const spawnChild = () => {
1676
+ const env = { ...process.env, FUSION_RELOAD_CHILD: '1' }
1677
+ child = spawn(process.execPath, process.argv.slice(1), {
1678
+ env,
1679
+ stdio: 'inherit',
1680
+ })
1681
+ return child
1682
+ }
1683
+
1684
+ const stopChild = () =>
1685
+ new Promise((resolve) => {
1686
+ if (!child || child.exitCode !== null) {
1687
+ child = null
1688
+ resolve()
1689
+ return
1690
+ }
1691
+ child.once('exit', () => {
1692
+ child = null
1693
+ resolve()
1694
+ })
1695
+ child.kill('SIGTERM')
1696
+ setTimeout(() => {
1697
+ if (child) child.kill('SIGKILL')
1698
+ }, 5000)
1699
+ })
1700
+
1701
+ const shutdown = async () => {
1702
+ await stopChild()
1703
+ process.exit(0)
1704
+ }
1705
+ process.on('SIGINT', shutdown)
1706
+ process.on('SIGTERM', shutdown)
1707
+
1708
+ let mtimes = snapshotMtimes(collectWatchedFiles(roots))
1709
+ spawnChild()
1710
+
1711
+ // eslint-disable-next-line no-constant-condition
1712
+ while (true) {
1713
+ await new Promise((r) => setTimeout(r, 500))
1714
+ if (child && child.exitCode !== null) {
1715
+ console.log(`fusion: child exited (${child.exitCode}); restarting…`)
1716
+ await new Promise((r) => setTimeout(r, 300))
1717
+ spawnChild()
1718
+ mtimes = snapshotMtimes(collectWatchedFiles(roots))
1719
+ continue
1720
+ }
1721
+ const files = collectWatchedFiles(roots)
1722
+ const next = snapshotMtimes(files)
1723
+ let changed = null
1724
+ for (const [file, mtime] of next) {
1725
+ const prev = mtimes.get(file)
1726
+ if (prev === undefined || mtime > prev) {
1727
+ changed = file
1728
+ break
1729
+ }
1730
+ }
1731
+ if (!changed) {
1732
+ for (const file of mtimes.keys()) {
1733
+ if (!next.has(file)) {
1734
+ changed = file
1735
+ break
1736
+ }
1737
+ }
1738
+ }
1739
+ if (!changed) continue
1740
+ let label = changed
1741
+ try {
1742
+ label = path.relative(process.cwd(), changed) || changed
1743
+ } catch {
1744
+ /* keep absolute */
1745
+ }
1746
+ console.log(`fusion: change detected (${label}); reloading…`)
1747
+ await stopChild()
1748
+ spawnChild()
1749
+ mtimes = snapshotMtimes(collectWatchedFiles(roots))
1750
+ }
1751
+ }
1752
+
967
1753
  async function run(options = {}) {
968
1754
  const settingsModulePath =
969
1755
  typeof options === 'string' ? options : options && options.settingsModule
@@ -980,7 +1766,12 @@ async function run(options = {}) {
980
1766
  }
981
1767
  const app = new FusionApp()
982
1768
  for (const mw of middleware) app.use(mw)
983
- await app.listen()
1769
+ await app.listen({
1770
+ reload: options && Object.prototype.hasOwnProperty.call(options, 'reload')
1771
+ ? options.reload
1772
+ : undefined,
1773
+ watchDirs: options?.watchDirs,
1774
+ })
984
1775
  return app
985
1776
  }
986
1777
 
@@ -1005,6 +1796,147 @@ function paginatedBody(items, total, params) {
1005
1796
  return native.paginatedBody(items, total, params)
1006
1797
  }
1007
1798
 
1799
+ /** Process-wide application cache (default driver: moka). */
1800
+ const cache = {
1801
+ _ready: false,
1802
+ _ensure() {
1803
+ if (this._ready) return
1804
+ try {
1805
+ // Use the native Settings singleton (not getSettings()'s plain object).
1806
+ settings.ensureLoaded([process.cwd()])
1807
+ native.cacheConfigure(settings)
1808
+ this._ready = true
1809
+ } catch {
1810
+ native.cacheConfigureDriver('moka', null, null)
1811
+ this._ready = true
1812
+ }
1813
+ },
1814
+ configure(settingsInstance) {
1815
+ const s = settingsInstance || settings
1816
+ if (s && typeof s.ensureLoaded === 'function') {
1817
+ s.ensureLoaded([process.cwd()])
1818
+ }
1819
+ native.cacheConfigure(s)
1820
+ this._ready = true
1821
+ },
1822
+ configureDriver(driver = 'moka', { maxCapacity, defaultTtl } = {}) {
1823
+ native.cacheConfigureDriver(driver, maxCapacity ?? null, defaultTtl ?? null)
1824
+ this._ready = true
1825
+ },
1826
+ set(key, value, ttl = null) {
1827
+ this._ensure()
1828
+ native.cacheSet(key, value, ttl)
1829
+ },
1830
+ get(key) {
1831
+ this._ensure()
1832
+ return native.cacheGet(key)
1833
+ },
1834
+ delete(key) {
1835
+ this._ensure()
1836
+ return native.cacheDelete(key)
1837
+ },
1838
+ exists(key) {
1839
+ this._ensure()
1840
+ return native.cacheExists(key)
1841
+ },
1842
+ getOrSet(key, defaultValue, ttl = null) {
1843
+ this._ensure()
1844
+ if (native.cacheExists(key)) return native.cacheGet(key)
1845
+ const value = typeof defaultValue === 'function' ? defaultValue() : defaultValue
1846
+ return native.cacheGetOrSet(key, value, ttl)
1847
+ },
1848
+ deleteOrSet(key, value, ttl = null) {
1849
+ this._ensure()
1850
+ return native.cacheDeleteOrSet(key, value, ttl)
1851
+ },
1852
+ existsOrSet(key, value, ttl = null) {
1853
+ this._ensure()
1854
+ return native.cacheExistsOrSet(key, value, ttl)
1855
+ },
1856
+ clear() {
1857
+ this._ensure()
1858
+ native.cacheClear()
1859
+ },
1860
+ driver() {
1861
+ this._ensure()
1862
+ return native.cacheDriver()
1863
+ },
1864
+ /** Live entries + recent mutations (monitor JSON). */
1865
+ snapshot() {
1866
+ this._ensure()
1867
+ return native.cacheSnapshot()
1868
+ },
1869
+ /** Template context for fusion/cache_monitor.html. */
1870
+ panelContext() {
1871
+ this._ensure()
1872
+ return native.cachePanelContext()
1873
+ },
1874
+ reset() {
1875
+ native.cacheReset()
1876
+ this._ready = false
1877
+ },
1878
+
1879
+ /** Async set (Promise). */
1880
+ async aset(key, value, ttl = null) {
1881
+ this.set(key, value, ttl)
1882
+ },
1883
+ async aget(key) {
1884
+ return this.get(key)
1885
+ },
1886
+ async adelete(key) {
1887
+ return this.delete(key)
1888
+ },
1889
+ async aexists(key) {
1890
+ return this.exists(key)
1891
+ },
1892
+ async agetOrSet(key, defaultValue, ttl = null) {
1893
+ this._ensure()
1894
+ if (native.cacheExists(key)) return native.cacheGet(key)
1895
+ let value = typeof defaultValue === 'function' ? defaultValue() : defaultValue
1896
+ if (value && typeof value.then === 'function') value = await value
1897
+ return native.cacheGetOrSet(key, value, ttl)
1898
+ },
1899
+ async adeleteOrSet(key, value, ttl = null) {
1900
+ return this.deleteOrSet(key, value, ttl)
1901
+ },
1902
+ async aexistsOrSet(key, value, ttl = null) {
1903
+ return this.existsOrSet(key, value, ttl)
1904
+ },
1905
+ async aclear() {
1906
+ this.clear()
1907
+ },
1908
+ }
1909
+
1910
+ /** Process-wide Tokio background tasks. */
1911
+ const tasks = {
1912
+ /** Run `fn` on the Tokio background runtime. Returns task id. */
1913
+ spawn(fn) {
1914
+ if (typeof fn !== 'function') throw new TypeError('callback must be a function')
1915
+ return native.taskSpawn(fn)
1916
+ },
1917
+ /** Run `fn` after `delayMs` milliseconds. Returns task id. */
1918
+ spawnAfter(delayMs, fn) {
1919
+ if (typeof fn !== 'function') throw new TypeError('callback must be a function')
1920
+ return native.taskSpawnAfter(Number(delayMs) || 0, fn)
1921
+ },
1922
+ /** Cancel a pending/running task. */
1923
+ cancel(taskId) {
1924
+ return native.taskCancel(String(taskId))
1925
+ },
1926
+ /** Status string, or null if unknown. */
1927
+ status(taskId) {
1928
+ return native.taskStatus(String(taskId))
1929
+ },
1930
+ /** JSON snapshot of tracked tasks (also under cache.snapshot().tasks). */
1931
+ snapshot() {
1932
+ return native.taskSnapshot()
1933
+ },
1934
+ /** Abort and clear all tracked tasks (tests). */
1935
+ reset() {
1936
+ native.taskReset()
1937
+ },
1938
+ }
1939
+
1008
1940
  const route = router
1009
1941
 
1010
1942
  module.exports = {
@@ -1012,6 +1944,8 @@ module.exports = {
1012
1944
  Settings: NativeSettings,
1013
1945
  FusionApp,
1014
1946
  FusionBaseApi,
1947
+ FusionBaseTemplate,
1948
+ parseFormBody,
1015
1949
  HTTPException,
1016
1950
  router,
1017
1951
  route,
@@ -1035,11 +1969,26 @@ module.exports = {
1035
1969
  run,
1036
1970
  bearerJwt,
1037
1971
  requireRoles,
1972
+ requirePermissions,
1038
1973
  frameworkHeaders,
1974
+ securityHeaders,
1975
+ cors,
1976
+ cacheHeaders,
1977
+ requestId,
1978
+ staticFiles,
1039
1979
  runMiddlewareChain,
1040
1980
  coerceParam,
1041
1981
  parsePagination,
1042
1982
  paginatedBody,
1983
+ cache,
1984
+ tasks,
1985
+ mountMonitor,
1986
+ mountCacheMonitor,
1987
+ renderTemplate,
1988
+ clearRouteRegistry,
1989
+ openapiSpec,
1990
+ routeVersions,
1991
+ hasUnversionedRoutes,
1043
1992
  getHttpMethods: () => HTTP_METHODS,
1044
1993
  apiResourceNameJs: native.apiResourceNameJs,
1045
1994
  resolveRoutePathJs: native.resolveRoutePathJs,