fusion-framework 1.3.0 → 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.3.0',
138
+ ['X-Fusion-Version']: '2.0.0',
138
139
  }
139
140
 
140
141
  function isThenable(value) {
@@ -155,319 +156,234 @@ function mergeResponseHeaders(result, extra) {
155
156
  if (!result || typeof result !== 'object') {
156
157
  return { status: 200, body: result, headers: { ...extra } }
157
158
  }
158
- const suppressed = new Set(
159
- (Array.isArray(result.suppress_headers) ? result.suppress_headers : []).map((n) =>
160
- String(n).toLowerCase(),
161
- ),
162
- )
163
- const filtered = {}
164
- for (const [k, v] of Object.entries(extra || {})) {
165
- if (!suppressed.has(String(k).toLowerCase())) filtered[k] = v
166
- }
167
- const headers = { ...filtered, ...(result.headers || {}) }
159
+ const headers = { ...extra, ...(result.headers || {}) }
168
160
  return { ...result, headers }
169
161
  }
170
162
 
171
- function stripResponseHeaders(result, names) {
172
- const drop = new Set(names.map((n) => String(n).toLowerCase()))
173
- const out =
174
- result && typeof result === 'object' && 'status' in result
175
- ? { ...result }
176
- : { status: 200, body: result }
177
- if (out.headers && typeof out.headers === 'object') {
178
- const headers = {}
179
- for (const [k, v] of Object.entries(out.headers)) {
180
- if (!drop.has(String(k).toLowerCase())) headers[k] = v
181
- }
182
- out.headers = headers
183
- }
184
- const suppressed = Array.isArray(out.suppress_headers) ? [...out.suppress_headers] : []
185
- const seen = new Set(suppressed.map((s) => String(s).toLowerCase()))
186
- for (const name of names) {
187
- const key = String(name).toLowerCase()
188
- if (!seen.has(key)) {
189
- suppressed.push(String(name))
190
- seen.add(key)
191
- }
192
- }
193
- out.suppress_headers = suppressed
194
- return out
195
- }
196
-
197
- function flattenHeaderMaps(...items) {
198
- const out = {}
199
- const strBuf = []
200
- for (const item of items) {
201
- if (item == null) continue
202
- if (typeof item === 'object' && !Array.isArray(item)) {
203
- for (const [k, v] of Object.entries(item)) out[String(k)] = v == null ? '' : String(v)
204
- } else if (Array.isArray(item) && item.length === 2 && typeof item[0] !== 'object') {
205
- out[String(item[0])] = item[1] == null ? '' : String(item[1])
206
- } else if (typeof item === 'string') {
207
- strBuf.push(item)
208
- }
209
- }
210
- if (strBuf.length >= 2 && strBuf.length % 2 === 0) {
211
- for (let i = 0; i < strBuf.length; i += 2) out[String(strBuf[i])] = String(strBuf[i + 1])
212
- }
213
- return out
214
- }
215
-
216
- function flattenHeaderNames(...items) {
217
- const names = []
218
- const seen = new Set()
219
- const add = (name) => {
220
- const key = String(name).toLowerCase()
221
- if (!key || seen.has(key)) return
222
- seen.add(key)
223
- names.push(String(name))
224
- }
225
- for (const item of items) {
226
- if (item == null) continue
227
- if (typeof item === 'string') add(item)
228
- else if (typeof item === 'object' && !Array.isArray(item)) {
229
- for (const k of Object.keys(item)) add(k)
230
- } else if (Array.isArray(item)) {
231
- if (item.length === 2 && typeof item[0] !== 'object') add(item[0])
232
- else item.forEach((x) => (typeof x === 'string' ? add(x) : null))
233
- }
234
- }
235
- return names
236
- }
237
-
238
- function wrapHeaderOp(fn, after) {
239
- const wrapped = function (...args) {
240
- const result = fn.apply(this, args)
241
- return Promise.resolve(result).then(after)
242
- }
243
- for (const key of Object.keys(fn)) {
244
- if (key.startsWith('__fusion')) wrapped[key] = fn[key]
245
- }
246
- if (fn.__fusionHttpRoute) wrapped.__fusionHttpRoute = fn.__fusionHttpRoute
247
- return wrapped
248
- }
249
-
250
- /** Merge headers into the method response (wins over middleware). */
251
- function addHeader(...items) {
252
- const headers = flattenHeaderMaps(...items)
253
- return (fn) => {
254
- const merged = { ...(fn.__fusionAddHeaders || {}), ...headers }
255
- const wrapped = wrapHeaderOp(fn, (result) => {
256
- if (!result || typeof result !== 'object') {
257
- return { status: 200, body: result, headers: { ...merged } }
258
- }
259
- return { ...result, headers: { ...(result.headers || {}), ...merged } }
260
- })
261
- wrapped.__fusionAddHeaders = merged
262
- return wrapped
163
+ function frameworkHeaders() {
164
+ const extra = header.fingerprint()
165
+ // Async so Promises from callNext are never stuffed into `body` as objects.
166
+ return async (request, callNext) => {
167
+ const result = await awaitMaybe(callNext(request))
168
+ return mergeResponseHeaders(result, extra)
263
169
  }
264
170
  }
265
171
 
266
- /** Strip headers from the method response and suppress wire fingerprint re-add. */
267
- function deleteHeader(...items) {
268
- const names = flattenHeaderNames(...items)
269
- return (fn) => {
270
- const merged = flattenHeaderNames(...(fn.__fusionDeleteHeaders || []), ...names)
271
- const wrapped = wrapHeaderOp(fn, (result) => stripResponseHeaders(result, merged))
272
- wrapped.__fusionDeleteHeaders = merged
273
- return wrapped
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)
274
177
  }
178
+ return null
275
179
  }
276
180
 
277
- function frameworkHeaders() {
278
- const extra = header.fingerprint()
279
- // Async so Promises from callNext are never stuffed into `body` as objects.
181
+ function headerMiddleware(extra) {
280
182
  return async (request, callNext) => {
281
183
  const result = await awaitMaybe(callNext(request))
282
184
  return mergeResponseHeaders(result, extra)
283
185
  }
284
186
  }
285
187
 
286
- function truthySetting(value, defaultValue = false) {
287
- if (value == null) return defaultValue
288
- if (typeof value === 'boolean') return value
289
- if (typeof value === 'number') return value !== 0
290
- if (typeof value === 'string') {
291
- const s = value.trim().toLowerCase()
292
- if (['', '0', 'false', 'off', 'no'].includes(s)) return false
293
- if (['1', 'true', 'on', 'yes'].includes(s)) return true
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',
294
197
  }
295
- return Boolean(value)
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)
296
201
  }
297
202
 
298
- function settingsSection(name) {
299
- const root = asObject(settings.get('middleware', {}))
300
- if (root && root[name] && typeof root[name] === 'object') return asObject(root[name])
301
- return asObject(settings.get(name, {}))
203
+ function cacheHeaders(options = {}) {
204
+ return headerMiddleware({
205
+ 'Cache-Control': options.default ?? options.value ?? 'no-store',
206
+ })
302
207
  }
303
208
 
304
- function asStringList(value, fallback = []) {
305
- if (value == null) return [...fallback]
306
- if (typeof value === 'string') {
307
- return value
308
- .split(',')
309
- .map((s) => s.trim())
310
- .filter(Boolean)
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 })
311
224
  }
312
- if (Array.isArray(value)) return value.map(String).filter(Boolean)
313
- return [String(value)]
314
225
  }
315
226
 
316
- function securityHeaders(config = {}) {
317
- const headers = {
318
- 'X-Content-Type-Options': String(config.content_type_options || config.x_content_type_options || 'nosniff'),
319
- 'X-Frame-Options': String(config.frame_options || config.x_frame_options || 'DENY'),
320
- 'Referrer-Policy': String(config.referrer_policy || 'strict-origin-when-cross-origin'),
321
- 'Permissions-Policy': String(
322
- config.permissions_policy || 'camera=(), microphone=(), geolocation=(), payment=()',
323
- ),
324
- 'X-XSS-Protection': String(config.xss_protection || '0'),
325
- 'Cross-Origin-Opener-Policy': String(config.coop || 'same-origin'),
326
- 'Cross-Origin-Resource-Policy': String(config.corp || 'same-origin'),
327
- }
328
- const csp = config.csp || config.content_security_policy
329
- if (csp) headers['Content-Security-Policy'] = String(csp)
330
- const hsts = config.hsts && typeof config.hsts === 'object' ? config.hsts : {}
331
- if (truthySetting(hsts.enabled, false)) {
332
- const maxAge = Number(hsts.max_age || 31536000)
333
- const parts = [`max-age=${maxAge}`]
334
- if (truthySetting(hsts.include_subdomains, true)) parts.push('includeSubDomains')
335
- if (truthySetting(hsts.preload, false)) parts.push('preload')
336
- headers['Strict-Transport-Security'] = parts.join('; ')
337
- }
338
- if (config.headers && typeof config.headers === 'object') {
339
- for (const [k, v] of Object.entries(config.headers)) {
340
- if (v == null) delete headers[k]
341
- else headers[k] = String(v)
342
- }
343
- }
344
- return async (request, callNext) => {
345
- const result = await awaitMaybe(callNext(request))
346
- return mergeResponseHeaders(result, headers)
347
- }
348
- }
349
-
350
- function cors(config = {}) {
351
- const allowOrigins = asStringList(config.allow_origins || config.origins, ['*'])
352
- const allowMethods = asStringList(config.allow_methods, [
353
- 'GET',
354
- 'POST',
355
- 'PUT',
356
- 'PATCH',
357
- 'DELETE',
358
- 'OPTIONS',
359
- 'HEAD',
360
- ])
361
- const allowHeaders = asStringList(config.allow_headers, [
362
- 'Authorization',
363
- 'Content-Type',
364
- 'Accept',
365
- 'Origin',
366
- 'X-Request-Id',
367
- ])
368
- const exposeHeaders = asStringList(config.expose_headers, ['X-Request-Id'])
369
- const allowCredentials = truthySetting(config.allow_credentials, false)
370
- const maxAge = Number(config.max_age || 600)
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('*')
371
241
 
372
242
  function corsHeaders(origin) {
373
- if (!allowOrigins.length) return null
374
- const wildcard = allowOrigins.includes('*')
375
- let allow
376
- if (origin && (wildcard || allowOrigins.includes(origin))) {
377
- allow = wildcard && !allowCredentials ? '*' : origin
378
- } else if (wildcard && !allowCredentials) {
379
- allow = '*'
380
- } else {
381
- return null
243
+ let chosen = '*'
244
+ if (!allowAll) {
245
+ if (origin && origins.includes(origin)) chosen = origin
246
+ else if (origins.length) chosen = origins[0]
382
247
  }
383
- const headers = {
384
- 'Access-Control-Allow-Origin': allow,
385
- 'Access-Control-Allow-Methods': allowMethods.join(', '),
248
+ const out = {
249
+ 'Access-Control-Allow-Origin': chosen,
250
+ 'Access-Control-Allow-Methods': methods.join(', '),
386
251
  'Access-Control-Allow-Headers': allowHeaders.join(', '),
252
+ 'Access-Control-Expose-Headers': exposeHeaders.join(', '),
387
253
  'Access-Control-Max-Age': String(maxAge),
388
254
  Vary: 'Origin',
389
255
  }
390
- if (exposeHeaders.length) headers['Access-Control-Expose-Headers'] = exposeHeaders.join(', ')
391
- if (allowCredentials) headers['Access-Control-Allow-Credentials'] = 'true'
392
- return headers
256
+ if (allowCredentials && chosen !== '*') out['Access-Control-Allow-Credentials'] = 'true'
257
+ return out
393
258
  }
394
259
 
395
260
  return async (request, callNext) => {
396
- const origin = request?.headers?.Origin || request?.headers?.origin || null
397
- const hdrs = corsHeaders(origin)
398
- const method = String(request?.method || '').toUpperCase()
399
- if (method === 'OPTIONS') {
400
- if (!hdrs) return { status: 403, body: { detail: 'CORS origin not allowed' } }
401
- return { status: 204, body: '', headers: hdrs }
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 }
402
265
  }
403
266
  const result = await awaitMaybe(callNext(request))
404
- return hdrs ? mergeResponseHeaders(result, hdrs) : result
267
+ return mergeResponseHeaders(result, extra)
405
268
  }
406
269
  }
407
270
 
408
- function cacheHeaders(config = {}) {
409
- const value = String(config.default || config.cache_control || 'no-store')
410
- const extra = { 'Cache-Control': value }
411
- if (config.pragma) extra.Pragma = String(config.pragma)
412
- else if (value === 'no-store') extra.Pragma = 'no-cache'
413
- return async (request, callNext) => {
414
- const result = await awaitMaybe(callNext(request))
415
- return mergeResponseHeaders(result, extra)
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)}`
416
327
  }
328
+ const body = String(method).toUpperCase() === 'HEAD' ? Buffer.alloc(0) : fs.readFileSync(filePath)
329
+ return { status: 200, body, headers }
417
330
  }
418
331
 
419
- function requestId(config = {}) {
420
- const headerName = String(config.header || 'X-Request-Id')
421
- const acceptIncoming = truthySetting(config.incoming, true)
422
- const stateKey = String(config.state_key || 'request_id')
423
- return async (request, callNext) => {
424
- const headers = request?.headers || {}
425
- let rid = null
426
- if (acceptIncoming) {
427
- for (const [k, v] of Object.entries(headers)) {
428
- if (String(k).toLowerCase() === headerName.toLowerCase()) {
429
- rid = String(v)
430
- break
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
431
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))
432
381
  }
433
382
  }
434
- if (!rid) {
435
- rid =
436
- typeof crypto !== 'undefined' && crypto.randomUUID
437
- ? crypto.randomUUID().replace(/-/g, '')
438
- : `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`
439
- }
440
- ensureState(request)[stateKey] = rid
441
- const result = await awaitMaybe(callNext(request))
442
- return mergeResponseHeaders(result, { [headerName]: rid })
383
+ walk(cfg.root)
443
384
  }
444
385
  }
445
386
 
446
- function defaultBuiltinMiddleware() {
447
- settings.ensureLoaded()
448
- const root = asObject(settings.get('middleware', {}))
449
- const enabled = (name, defaultOn) => {
450
- const cfg = settingsSection(name)
451
- if (Object.prototype.hasOwnProperty.call(cfg, 'enabled')) {
452
- return [truthySetting(cfg.enabled, defaultOn), cfg]
453
- }
454
- if (root && Object.keys(root).length) return [defaultOn, cfg]
455
- return [defaultOn, cfg]
456
- }
457
- const out = []
458
- let cfg
459
- let on
460
- ;[on, cfg] = enabled('security', true)
461
- if (on) out.push(securityHeaders(cfg))
462
- ;[on, cfg] = enabled('cors', false)
463
- if (on) out.push(cors(cfg))
464
- ;[on, cfg] = enabled('cache', false)
465
- if (on) out.push(cacheHeaders(cfg))
466
- ;[on, cfg] = enabled('request_id', true)
467
- if (on) out.push(requestId(cfg))
468
- return out
469
- }
470
-
471
387
  class FusionBaseApi {
472
388
  constructor(request) {
473
389
  this.request = request && typeof request === 'object' ? request : emptyRequest()
@@ -528,6 +444,213 @@ class FusionBaseApi {
528
444
  const body = paginatedBody(items, total, p)
529
445
  return this.response(body, status, headers || {})
530
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)
531
654
  }
532
655
 
533
656
  function apiResourceName(cls) {
@@ -721,6 +844,17 @@ async function runMiddlewareChain(request, middlewares, handler) {
721
844
  return dispatch(0, request)
722
845
  }
723
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
+
724
858
  function requireRoles(...rolesOrOptions) {
725
859
  let roles = rolesOrOptions
726
860
  let claim = 'roles'
@@ -804,14 +938,9 @@ function router(routePath, options = {}) {
804
938
  ApiClass.__fusion_path_template__ = routePath
805
939
 
806
940
  const routeMiddleware = Array.isArray(options.middleware) ? [...options.middleware] : []
807
- if (Array.isArray(options.roles) && options.roles.length) {
808
- routeMiddleware.push(
809
- requireRoles({
810
- roles: options.roles,
811
- claim: options.roleClaim || 'roles',
812
- stateKey: options.roleStateKey || 'jwt',
813
- }),
814
- )
941
+ const permissionChecks = Array.isArray(options.permissions) ? options.permissions : []
942
+ if (permissionChecks.length) {
943
+ routeMiddleware.push(requirePermissions(...permissionChecks))
815
944
  }
816
945
 
817
946
  const classSwagger = {
@@ -828,6 +957,7 @@ function router(routePath, options = {}) {
828
957
  middleware: routeMiddleware,
829
958
  swagger: classSwagger,
830
959
  version_prefix: v,
960
+ requiresPermissions: permissionChecks.length > 0,
831
961
  slots: collectRouteSlots(ApiClass, resolved, classSwagger),
832
962
  })
833
963
  return ApiClass
@@ -953,7 +1083,7 @@ function readSwaggerSettings() {
953
1083
  showCommonExtensions: false,
954
1084
  syntaxHighlight: { activated: true, theme: 'agate' },
955
1085
  withCredentials: false,
956
- validatorUrl: 'https://validator.swagger.io/validator',
1086
+ validatorUrl: null,
957
1087
  ...asObject(settings.get('swagger.ui', {})),
958
1088
  }
959
1089
  if (Object.prototype.hasOwnProperty.call(authRaw, 'persistAuthorization')) {
@@ -982,6 +1112,102 @@ function readSwaggerSettings() {
982
1112
 
983
1113
  const UNVERSIONED_SWAGGER_NAME = 'default'
984
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
+
985
1211
  function normalizeVersionLabel(value) {
986
1212
  return String(value || '')
987
1213
  .trim()
@@ -1002,6 +1228,40 @@ function collectRouteVersions() {
1002
1228
  return { versions, hasUnversioned }
1003
1229
  }
1004
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
+
1005
1265
  function swaggerVersionUrls(prefix) {
1006
1266
  const { versions, hasUnversioned } = collectRouteVersions()
1007
1267
  const urls = versions.map((label) => ({
@@ -1048,6 +1308,17 @@ function applySwaggerOpenApi(openapi, swagger) {
1048
1308
  return openapi
1049
1309
  }
1050
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
+
1051
1322
  function fillOpenApiPaths(openapi, versionFilter = null) {
1052
1323
  const parsePathParams = (pattern) => {
1053
1324
  return String(pattern)
@@ -1056,9 +1327,13 @@ function fillOpenApiPaths(openapi, versionFilter = null) {
1056
1327
  .map((seg) => seg.slice(1, -1))
1057
1328
  }
1058
1329
 
1330
+ let anyPermissions = false
1331
+
1059
1332
  for (const item of registry) {
1060
1333
  if (!routeMatchesVersion(item, versionFilter)) continue
1061
- const { ApiClass, swagger: routeSwagger } = item
1334
+ const { ApiClass, swagger: routeSwagger, requiresPermissions } = item
1335
+ if (isTemplateClass(ApiClass)) continue
1336
+ if (requiresPermissions) anyPermissions = true
1062
1337
  const slots = item.slots || []
1063
1338
 
1064
1339
  for (const slot of slots) {
@@ -1084,10 +1359,27 @@ function fillOpenApiPaths(openapi, versionFilter = null) {
1084
1359
  deprecated: !!routeSwaggerEntry?.deprecated,
1085
1360
  operationId: `${ApiClass.name}_${slot.handlerMethod}`,
1086
1361
  parameters: params,
1087
- 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]: [] }] } : {}),
1088
1367
  }
1089
1368
  }
1090
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
+ }
1091
1383
  return openapi
1092
1384
  }
1093
1385
 
@@ -1135,6 +1427,8 @@ function swaggerUiHtml(swagger, openapiUrl, primaryName = null) {
1135
1427
 
1136
1428
  const navbarEnabled = !!swagger.navbar?.enabled
1137
1429
  const showUrlInput = swagger.navbar?.showUrlInput !== false
1430
+ const versionUrls = swagger.navbar?.urls?.length > 0
1431
+ const needsStandalone = navbarEnabled || versionUrls
1138
1432
  const hideUrlCss =
1139
1433
  navbarEnabled && !showUrlInput
1140
1434
  ? `<style>
@@ -1144,9 +1438,10 @@ function swaggerUiHtml(swagger, openapiUrl, primaryName = null) {
1144
1438
  }
1145
1439
  </style>`
1146
1440
  : ''
1147
- const standaloneScript = navbarEnabled
1148
- ? `<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"></script>`
1149
- : ''
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
+ : ''
1150
1445
 
1151
1446
  return `<!doctype html>
1152
1447
  <html>
@@ -1154,19 +1449,19 @@ function swaggerUiHtml(swagger, openapiUrl, primaryName = null) {
1154
1449
  <meta charset="utf-8" />
1155
1450
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1156
1451
  <title>${title}</title>
1157
- <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
1452
+ <link rel="stylesheet" href="${swaggerAssetUrl(swagger.path, 'swagger-ui.css')}" />
1158
1453
  ${hideUrlCss}
1159
1454
  </head>
1160
1455
  <body>
1161
1456
  <div id="swagger-ui"></div>
1162
- <script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
1457
+ <script src="${swaggerAssetUrl(swagger.path, 'swagger-ui-bundle.js')}"></script>
1163
1458
  ${standaloneScript}
1164
1459
  <script>
1165
1460
  window.onload = function() {
1166
1461
  var opts = ${uiJson};
1167
1462
  opts.presets = [SwaggerUIBundle.presets.apis];
1168
1463
  opts.plugins = [SwaggerUIBundle.plugins.DownloadUrl];
1169
- if (${navbarEnabled ? 'true' : 'false'} && typeof SwaggerUIStandalonePreset !== 'undefined') {
1464
+ if (${needsStandalone ? 'true' : 'false'} && typeof SwaggerUIStandalonePreset !== 'undefined') {
1170
1465
  opts.presets.push(SwaggerUIStandalonePreset);
1171
1466
  opts.layout = 'StandaloneLayout';
1172
1467
  } else {
@@ -1222,9 +1517,12 @@ class FusionApp {
1222
1517
  }
1223
1518
  }
1224
1519
 
1520
+ mountStaticFiles(this.engine, this._middleware)
1521
+
1225
1522
  const swagger = readSwaggerSettings()
1226
1523
  if (swagger.enabled) {
1227
1524
  const prefix = swagger.path
1525
+ mountSwaggerAssets(this.engine, prefix)
1228
1526
  const labels = applyVersionNavbar(swagger)
1229
1527
  const combined = buildOpenApi(swagger)
1230
1528
 
@@ -1248,21 +1546,210 @@ class FusionApp {
1248
1546
  }
1249
1547
  }
1250
1548
 
1549
+ mountMonitor(this.engine, settings)
1550
+
1251
1551
  this.mounted = true
1252
1552
  }
1253
1553
 
1254
- async listen(host, port) {
1255
- 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
+
1256
1573
  const snapshot = getSettings()
1257
- const h = host ?? snapshot.host
1258
- const p = port ?? snapshot.port
1259
- if (snapshot.debug) {
1260
- 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}`)
1261
1594
  }
1262
1595
  await this.engine.listen(h, Number(p))
1263
1596
  }
1264
1597
  }
1265
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
+
1266
1753
  async function run(options = {}) {
1267
1754
  const settingsModulePath =
1268
1755
  typeof options === 'string' ? options : options && options.settingsModule
@@ -1279,7 +1766,12 @@ async function run(options = {}) {
1279
1766
  }
1280
1767
  const app = new FusionApp()
1281
1768
  for (const mw of middleware) app.use(mw)
1282
- 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
+ })
1283
1775
  return app
1284
1776
  }
1285
1777
 
@@ -1304,6 +1796,147 @@ function paginatedBody(items, total, params) {
1304
1796
  return native.paginatedBody(items, total, params)
1305
1797
  }
1306
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
+
1307
1940
  const route = router
1308
1941
 
1309
1942
  module.exports = {
@@ -1311,6 +1944,8 @@ module.exports = {
1311
1944
  Settings: NativeSettings,
1312
1945
  FusionApp,
1313
1946
  FusionBaseApi,
1947
+ FusionBaseTemplate,
1948
+ parseFormBody,
1314
1949
  HTTPException,
1315
1950
  router,
1316
1951
  route,
@@ -1334,18 +1969,26 @@ module.exports = {
1334
1969
  run,
1335
1970
  bearerJwt,
1336
1971
  requireRoles,
1972
+ requirePermissions,
1337
1973
  frameworkHeaders,
1338
1974
  securityHeaders,
1339
1975
  cors,
1340
1976
  cacheHeaders,
1341
1977
  requestId,
1342
- defaultBuiltinMiddleware,
1343
- addHeader,
1344
- deleteHeader,
1978
+ staticFiles,
1345
1979
  runMiddlewareChain,
1346
1980
  coerceParam,
1347
1981
  parsePagination,
1348
1982
  paginatedBody,
1983
+ cache,
1984
+ tasks,
1985
+ mountMonitor,
1986
+ mountCacheMonitor,
1987
+ renderTemplate,
1988
+ clearRouteRegistry,
1989
+ openapiSpec,
1990
+ routeVersions,
1991
+ hasUnversionedRoutes,
1349
1992
  getHttpMethods: () => HTTP_METHODS,
1350
1993
  apiResourceNameJs: native.apiResourceNameJs,
1351
1994
  resolveRoutePathJs: native.resolveRoutePathJs,