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/README.md +3 -1
- package/fusion-node.darwin-arm64.node +0 -0
- package/fusion-node.linux-arm64-gnu.node +0 -0
- package/fusion-node.linux-x64-gnu.node +0 -0
- package/fusion-node.win32-x64-msvc.node +0 -0
- package/index.d.ts +82 -143
- package/index.js +976 -27
- package/package.json +46 -11
- package/static/swagger-ui/LICENSE +202 -0
- package/static/swagger-ui/VERSION +1 -0
- package/static/swagger-ui/favicon-32x32.png +0 -0
- package/static/swagger-ui/swagger-ui-bundle.js +2 -0
- package/static/swagger-ui/swagger-ui-standalone-preset.js +2 -0
- package/static/swagger-ui/swagger-ui.css +3 -0
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']: '
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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:
|
|
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: {
|
|
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 =
|
|
848
|
-
|
|
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="
|
|
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="
|
|
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 (${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
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,
|