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/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 +937 -294
- 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) {
|
|
@@ -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
|
|
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
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
203
|
+
function cacheHeaders(options = {}) {
|
|
204
|
+
return headerMiddleware({
|
|
205
|
+
'Cache-Control': options.default ?? options.value ?? 'no-store',
|
|
206
|
+
})
|
|
302
207
|
}
|
|
303
208
|
|
|
304
|
-
function
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
'
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
'
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
384
|
-
'Access-Control-Allow-Origin':
|
|
385
|
-
'Access-Control-Allow-Methods':
|
|
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 (
|
|
391
|
-
|
|
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
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
267
|
+
return mergeResponseHeaders(result, extra)
|
|
405
268
|
}
|
|
406
269
|
}
|
|
407
270
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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:
|
|
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: {
|
|
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 =
|
|
1148
|
-
|
|
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="
|
|
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="
|
|
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 (${
|
|
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
|
-
|
|
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
|
-
|
|
1258
|
-
const
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
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,
|