webspresso 0.0.99 → 0.0.101
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/.agents/ADMIN.md +47 -4
- package/.agents/AGENTS.md +4 -2
- package/.agents/AUTH.md +309 -0
- package/.agents/CLI.md +128 -0
- package/.agents/EMAIL.md +84 -2
- package/.agents/ERRORS.md +59 -0
- package/.agents/EVENTS.md +179 -0
- package/.agents/ORM.md +149 -0
- package/.agents/PLUGINS.md +44 -7
- package/.agents/QUEUE.md +18 -10
- package/.agents/ROUTING.md +97 -1
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/db-makemigrations.js +83 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/commands/list.js +96 -0
- package/bin/commands/make-command.js +93 -0
- package/bin/commands/make-event.js +80 -0
- package/bin/commands/make-factory.js +72 -0
- package/bin/commands/make-mail.js +93 -0
- package/bin/commands/make-policy.js +106 -0
- package/bin/commands/run.js +195 -0
- package/bin/webspresso.js +21 -0
- package/core/auth/bouncer.js +196 -0
- package/core/auth/index.js +73 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +343 -3
- package/core/auth/middleware.js +45 -6
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +4 -1
- package/core/auth/user-agent.js +161 -0
- package/core/commands/define-command.js +48 -0
- package/core/commands/index.js +18 -0
- package/core/commands/registry.js +156 -0
- package/core/commands/runner.js +76 -0
- package/core/compression/compressible.js +1 -1
- package/core/compression/middleware.js +35 -251
- package/core/content/service.js +8 -2
- package/core/events/discovery.js +142 -0
- package/core/events/dispatcher.js +552 -0
- package/core/events/index.js +23 -0
- package/core/events/signal.js +92 -0
- package/core/forms/flash.js +234 -0
- package/core/forms/form-builder.js +214 -0
- package/core/forms/index.js +16 -0
- package/core/mail/index.js +23 -0
- package/core/mail/mailable.js +250 -0
- package/core/mail/mailer.js +184 -0
- package/core/mail/transports.js +238 -0
- package/core/orm/factory.js +250 -0
- package/core/orm/index.js +8 -1
- package/core/orm/migrations/diff.js +185 -0
- package/core/orm/migrations/index.js +22 -4
- package/core/orm/model.js +55 -1
- package/core/orm/repository.js +283 -13
- package/core/orm/scopes.js +3 -2
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +252 -39
- package/core/queue/manager.js +20 -2
- package/core/queue/scheduler.js +115 -59
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +540 -14
- package/index.js +44 -0
- package/package.json +11 -5
- package/plugins/admin-panel/api.js +509 -3
- package/plugins/admin-panel/index.js +17 -0
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/audit-log/admin-component.js +54 -54
- package/plugins/audit-log/index.js +2 -2
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/content/models/content-entry.js +4 -3
- package/plugins/content/models/content-type.js +4 -3
- package/plugins/cors.js +15 -4
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/index.js +48 -0
- package/plugins/email/preview-handler.js +555 -0
- package/plugins/email/service.js +23 -12
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/email/transport.js +30 -11
- package/plugins/file-manager/admin-component.js +49 -49
- package/plugins/file-manager/index.js +4 -4
- package/plugins/index.js +2 -0
- package/plugins/redirect/index.js +1 -1
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -1
- package/src/app-context.js +48 -6
- package/src/bundle/index.js +2 -1
- package/src/dev-reload/index.js +9 -1
- package/src/discovery/scan-routes.js +10 -1
- package/src/file-router.js +173 -4
- package/src/helpers.js +150 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/pages/page-loader.js +1 -1
- package/src/routing/cascading.js +120 -4
- package/src/server.js +180 -26
package/src/helpers.js
CHANGED
|
@@ -294,7 +294,13 @@ class AssetManager {
|
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
if (routePath) {
|
|
297
|
-
let cleanPath = String(routePath).trim()
|
|
297
|
+
let cleanPath = String(routePath).trim();
|
|
298
|
+
while (cleanPath.startsWith('/')) {
|
|
299
|
+
cleanPath = cleanPath.slice(1);
|
|
300
|
+
}
|
|
301
|
+
while (cleanPath.endsWith('/')) {
|
|
302
|
+
cleanPath = cleanPath.slice(0, -1);
|
|
303
|
+
}
|
|
298
304
|
if (cleanPath) {
|
|
299
305
|
candidates.add(cleanPath);
|
|
300
306
|
if (cleanPath.includes(':')) {
|
|
@@ -557,6 +563,149 @@ function createHelpers(ctx) {
|
|
|
557
563
|
/** Active i18n translator function */
|
|
558
564
|
t,
|
|
559
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Get old input value from previous request (AdonisJS & Laravel-inspired)
|
|
568
|
+
* @param {string} [name] - Field name
|
|
569
|
+
* @param {any} [defaultValue=null] - Default fallback value
|
|
570
|
+
* @returns {any}
|
|
571
|
+
*/
|
|
572
|
+
old(name, defaultValue = null) {
|
|
573
|
+
if (!name) return (req && typeof req.old === 'function') ? req.old() : (req?.__flash?.old || {});
|
|
574
|
+
if (req && typeof req.old === 'function') {
|
|
575
|
+
return req.old(name, defaultValue);
|
|
576
|
+
}
|
|
577
|
+
const oldVals = req?.__flash?.old || {};
|
|
578
|
+
return oldVals[name] !== undefined ? oldVals[name] : defaultValue;
|
|
579
|
+
},
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Get first validation error message for a specific field or general form
|
|
583
|
+
* @param {string} [field='_form']
|
|
584
|
+
* @returns {string|null}
|
|
585
|
+
*/
|
|
586
|
+
error(field = '_form') {
|
|
587
|
+
const errors = req?.__flash?.errors || {};
|
|
588
|
+
const list = errors[field];
|
|
589
|
+
if (Array.isArray(list) && list.length > 0) return list[0];
|
|
590
|
+
if (typeof list === 'string') return list;
|
|
591
|
+
return null;
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Get all validation error messages for a field
|
|
596
|
+
* @param {string} [field]
|
|
597
|
+
* @returns {string[]|Record<string, string[]>}
|
|
598
|
+
*/
|
|
599
|
+
errors(field) {
|
|
600
|
+
const errs = req?.__flash?.errors || {};
|
|
601
|
+
if (!field) return errs;
|
|
602
|
+
const list = errs[field];
|
|
603
|
+
if (Array.isArray(list)) return list;
|
|
604
|
+
if (typeof list === 'string') return [list];
|
|
605
|
+
return [];
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Check if a field or the form has any validation errors
|
|
610
|
+
* @param {string} [field]
|
|
611
|
+
* @returns {boolean}
|
|
612
|
+
*/
|
|
613
|
+
hasError(field) {
|
|
614
|
+
const errs = req?.__flash?.errors || {};
|
|
615
|
+
if (!field) return Object.keys(errs).length > 0;
|
|
616
|
+
return Boolean(errs[field] && (Array.isArray(errs[field]) ? errs[field].length > 0 : true));
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Get all validation errors dictionary
|
|
621
|
+
* @returns {Record<string, string[]>}
|
|
622
|
+
*/
|
|
623
|
+
allErrors() {
|
|
624
|
+
return req?.__flash?.errors || {};
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Get flash message by key (e.g. 'success', 'error', 'info')
|
|
629
|
+
* @param {string} [key]
|
|
630
|
+
* @returns {any}
|
|
631
|
+
*/
|
|
632
|
+
flash(key) {
|
|
633
|
+
if (!req) return null;
|
|
634
|
+
if (typeof req.flash === 'function') {
|
|
635
|
+
return key ? req.flash(key) : req.flash();
|
|
636
|
+
}
|
|
637
|
+
const data = req.__flash || {};
|
|
638
|
+
return key ? data[key] : data;
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Check if a flash message exists for a key
|
|
643
|
+
* @param {string} [key]
|
|
644
|
+
* @returns {boolean}
|
|
645
|
+
*/
|
|
646
|
+
hasFlash(key) {
|
|
647
|
+
const data = (req && typeof req.flash === 'function') ? req.flash() : (req?.__flash || {});
|
|
648
|
+
return key ? (data[key] !== undefined && data[key] !== null) : Object.keys(data).length > 0;
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Generate CSRF hidden input or token string
|
|
653
|
+
* @param {boolean} [asInput=true]
|
|
654
|
+
* @returns {string}
|
|
655
|
+
*/
|
|
656
|
+
csrf(asInput = true) {
|
|
657
|
+
const token = req?.csrfToken ? req.csrfToken() : (req?.session?._csrf || req?.__csrfToken || '');
|
|
658
|
+
if (!asInput) return token;
|
|
659
|
+
return token ? `<input type="hidden" name="_csrf" value="${token}">` : '';
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Render an accessible HTML form field (Django-inspired)
|
|
664
|
+
* @param {string} name
|
|
665
|
+
* @param {Object} [options={}]
|
|
666
|
+
* @returns {string} HTML string
|
|
667
|
+
*/
|
|
668
|
+
field(name, options = {}) {
|
|
669
|
+
const { renderField } = require('../core/forms/form-builder');
|
|
670
|
+
return renderField(name, options, { req, res });
|
|
671
|
+
},
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Render a complete HTML form from an ORM model definition (Django ModelForm-inspired)
|
|
675
|
+
* @param {Object} modelOrRepo
|
|
676
|
+
* @param {Object} [options={}]
|
|
677
|
+
* @returns {string} HTML string
|
|
678
|
+
*/
|
|
679
|
+
formFor(modelOrRepo, options = {}) {
|
|
680
|
+
const { renderFormFor } = require('../core/forms/form-builder');
|
|
681
|
+
return renderFormFor(modelOrRepo, options, { req, res });
|
|
682
|
+
},
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Check authorization for current user (AdonisJS Bouncer & Laravel Can-inspired)
|
|
686
|
+
* @param {string|Function} actionOrPolicy - e.g. 'Post.update' or 'admin'
|
|
687
|
+
* @param {...any} args - Resource or additional arguments
|
|
688
|
+
* @returns {boolean}
|
|
689
|
+
*/
|
|
690
|
+
can(actionOrPolicy, ...args) {
|
|
691
|
+
if (req && req.bouncer && typeof req.bouncer.allows === 'function') {
|
|
692
|
+
return req.bouncer.allows(actionOrPolicy, ...args);
|
|
693
|
+
}
|
|
694
|
+
const { createBouncer } = require('../core/auth/bouncer');
|
|
695
|
+
const user = req?.user || req?.auth?.user || null;
|
|
696
|
+
return createBouncer(user).allows(actionOrPolicy, ...args);
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Check if current user cannot perform action
|
|
701
|
+
* @param {string|Function} actionOrPolicy
|
|
702
|
+
* @param {...any} args
|
|
703
|
+
* @returns {boolean}
|
|
704
|
+
*/
|
|
705
|
+
cannot(actionOrPolicy, ...args) {
|
|
706
|
+
return !this.can(actionOrPolicy, ...args);
|
|
707
|
+
},
|
|
708
|
+
|
|
560
709
|
/**
|
|
561
710
|
* Generate URL for switching to a target locale with current query parameters preserved
|
|
562
711
|
* @param {string} targetLocale - Locale code to switch to (e.g. 'en', 'tr')
|
package/src/njk-frontmatter.js
CHANGED
|
@@ -83,6 +83,9 @@ function frontmatterToPatches(fm) {
|
|
|
83
83
|
if (d && typeof d === 'object' && !Array.isArray(d)) {
|
|
84
84
|
dataPatch = { ...d };
|
|
85
85
|
}
|
|
86
|
+
if (Object.prototype.hasOwnProperty.call(fmObj, 'layout') && fmObj.layout !== undefined) {
|
|
87
|
+
dataPatch.layout = fmObj.layout;
|
|
88
|
+
}
|
|
86
89
|
|
|
87
90
|
return { metaPatch, dataPatch };
|
|
88
91
|
}
|
package/src/pages/page-loader.js
CHANGED
|
@@ -48,7 +48,7 @@ function createPageHandler(descriptor, context) {
|
|
|
48
48
|
options: appOptions = {},
|
|
49
49
|
} = context;
|
|
50
50
|
|
|
51
|
-
const isDev = process.env.NODE_ENV === 'development';
|
|
51
|
+
const isDev = context.isDev !== undefined ? context.isDev : (appOptions.isDev !== undefined ? appOptions.isDev : (process.env.NODE_ENV === 'development' && process.env.NODE_ENV !== 'test'));
|
|
52
52
|
const pagesDir = path.dirname(descriptor.file);
|
|
53
53
|
const njkCandidate = descriptor.file.endsWith('.njk')
|
|
54
54
|
? descriptor.file
|
package/src/routing/cascading.js
CHANGED
|
@@ -16,6 +16,12 @@ const EMPTY_ARRAY = Object.freeze([]);
|
|
|
16
16
|
// Caches for production mode (key: directory path)
|
|
17
17
|
const middlewareFileCache = new Map();
|
|
18
18
|
const layoutFileCache = new Map();
|
|
19
|
+
const errorFileCache = new Map();
|
|
20
|
+
const loadingFileCache = new Map();
|
|
21
|
+
|
|
22
|
+
function shouldBypassCache(isDev) {
|
|
23
|
+
return !!isDev;
|
|
24
|
+
}
|
|
19
25
|
|
|
20
26
|
/**
|
|
21
27
|
* Returns ancestor directory segments from root down to file's directory.
|
|
@@ -77,7 +83,7 @@ function findCascadingMiddlewareFiles(baseDir, relativeFilePath, isDev = false)
|
|
|
77
83
|
const chain = getDirectoryChain(relativeFilePath);
|
|
78
84
|
const cacheKey = `${baseDir}::${chain.join('>')}`;
|
|
79
85
|
|
|
80
|
-
if (!isDev && middlewareFileCache.has(cacheKey)) {
|
|
86
|
+
if (!shouldBypassCache(isDev) && middlewareFileCache.has(cacheKey)) {
|
|
81
87
|
return middlewareFileCache.get(cacheKey);
|
|
82
88
|
}
|
|
83
89
|
|
|
@@ -92,7 +98,7 @@ function findCascadingMiddlewareFiles(baseDir, relativeFilePath, isDev = false)
|
|
|
92
98
|
}
|
|
93
99
|
}
|
|
94
100
|
|
|
95
|
-
if (!isDev) {
|
|
101
|
+
if (!shouldBypassCache(isDev)) {
|
|
96
102
|
middlewareFileCache.set(cacheKey, existingFiles);
|
|
97
103
|
}
|
|
98
104
|
|
|
@@ -242,7 +248,7 @@ function findCascadingLayouts(baseDir, relativeFilePath, isDev = false) {
|
|
|
242
248
|
const chain = getDirectoryChain(relativeFilePath);
|
|
243
249
|
const cacheKey = `${baseDir}::${chain.join('>')}`;
|
|
244
250
|
|
|
245
|
-
if (!isDev && layoutFileCache.has(cacheKey)) {
|
|
251
|
+
if (!shouldBypassCache(isDev) && layoutFileCache.has(cacheKey)) {
|
|
246
252
|
return layoutFileCache.get(cacheKey);
|
|
247
253
|
}
|
|
248
254
|
|
|
@@ -273,7 +279,7 @@ function findCascadingLayouts(baseDir, relativeFilePath, isDev = false) {
|
|
|
273
279
|
}
|
|
274
280
|
}
|
|
275
281
|
|
|
276
|
-
if (!isDev) {
|
|
282
|
+
if (!shouldBypassCache(isDev)) {
|
|
277
283
|
layoutFileCache.set(cacheKey, layouts);
|
|
278
284
|
}
|
|
279
285
|
|
|
@@ -359,9 +365,117 @@ function applyCascadingLayouts(nunjucksEnv, layouts, pageHtml, renderContext, is
|
|
|
359
365
|
return currentHtml;
|
|
360
366
|
}
|
|
361
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Discover the nearest _error.njk error boundary template in the directory hierarchy
|
|
370
|
+
* (from the file's directory segment climbing up to the root pages directory).
|
|
371
|
+
*
|
|
372
|
+
* @param {string} baseDir - Base pages directory
|
|
373
|
+
* @param {string} relativeFilePath - Relative file path (e.g. 'dashboard/billing/invoices.njk')
|
|
374
|
+
* @param {boolean} [isDev=false]
|
|
375
|
+
* @returns {{ dirSegment: string, errorRelPath: string, fullPath: string, configPath: string|null }|null}
|
|
376
|
+
*/
|
|
377
|
+
function findNearestErrorBoundary(baseDir, relativeFilePath, isDev = false) {
|
|
378
|
+
const chain = getDirectoryChain(relativeFilePath);
|
|
379
|
+
const cacheKey = `${baseDir}::${chain.join('>')}`;
|
|
380
|
+
|
|
381
|
+
if (!shouldBypassCache(isDev) && errorFileCache.has(cacheKey)) {
|
|
382
|
+
return errorFileCache.get(cacheKey);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const reversedChain = [...chain].reverse();
|
|
386
|
+
|
|
387
|
+
for (const dirSegment of reversedChain) {
|
|
388
|
+
const errorNjkName = '_error.njk';
|
|
389
|
+
const errorJsName = '_error.js';
|
|
390
|
+
|
|
391
|
+
const errorRelPath = dirSegment === ''
|
|
392
|
+
? errorNjkName
|
|
393
|
+
: `${dirSegment}/${errorNjkName}`;
|
|
394
|
+
|
|
395
|
+
const fullPath = dirSegment === ''
|
|
396
|
+
? path.join(baseDir, errorNjkName)
|
|
397
|
+
: path.join(baseDir, ...dirSegment.split('/'), errorNjkName);
|
|
398
|
+
|
|
399
|
+
const fullJsPath = dirSegment === ''
|
|
400
|
+
? path.join(baseDir, errorJsName)
|
|
401
|
+
: path.join(baseDir, ...dirSegment.split('/'), errorJsName);
|
|
402
|
+
|
|
403
|
+
if (fs.existsSync(fullPath)) {
|
|
404
|
+
const result = {
|
|
405
|
+
dirSegment,
|
|
406
|
+
errorRelPath: errorRelPath.split(path.sep).join('/'),
|
|
407
|
+
fullPath,
|
|
408
|
+
configPath: fs.existsSync(fullJsPath) ? fullJsPath : null,
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
if (!shouldBypassCache(isDev)) {
|
|
412
|
+
errorFileCache.set(cacheKey, result);
|
|
413
|
+
}
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!shouldBypassCache(isDev)) {
|
|
419
|
+
errorFileCache.set(cacheKey, null);
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Discover the nearest _loading.njk loading/skeleton template in the directory hierarchy
|
|
426
|
+
* (from the file's directory segment climbing up to the root pages directory).
|
|
427
|
+
*
|
|
428
|
+
* @param {string} baseDir - Base pages directory
|
|
429
|
+
* @param {string} relativeFilePath - Relative file path (e.g. 'dashboard/billing/invoices.njk')
|
|
430
|
+
* @param {boolean} [isDev=false]
|
|
431
|
+
* @returns {{ dirSegment: string, loadingRelPath: string, fullPath: string }|null}
|
|
432
|
+
*/
|
|
433
|
+
function findNearestLoadingTemplate(baseDir, relativeFilePath, isDev = false) {
|
|
434
|
+
const chain = getDirectoryChain(relativeFilePath);
|
|
435
|
+
const cacheKey = `${baseDir}::${chain.join('>')}`;
|
|
436
|
+
|
|
437
|
+
if (!shouldBypassCache(isDev) && loadingFileCache.has(cacheKey)) {
|
|
438
|
+
return loadingFileCache.get(cacheKey);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const reversedChain = [...chain].reverse();
|
|
442
|
+
|
|
443
|
+
for (const dirSegment of reversedChain) {
|
|
444
|
+
const loadingNjkName = '_loading.njk';
|
|
445
|
+
|
|
446
|
+
const loadingRelPath = dirSegment === ''
|
|
447
|
+
? loadingNjkName
|
|
448
|
+
: `${dirSegment}/${loadingNjkName}`;
|
|
449
|
+
|
|
450
|
+
const fullPath = dirSegment === ''
|
|
451
|
+
? path.join(baseDir, loadingNjkName)
|
|
452
|
+
: path.join(baseDir, ...dirSegment.split('/'), loadingNjkName);
|
|
453
|
+
|
|
454
|
+
if (fs.existsSync(fullPath)) {
|
|
455
|
+
const result = {
|
|
456
|
+
dirSegment,
|
|
457
|
+
loadingRelPath: loadingRelPath.split(path.sep).join('/'),
|
|
458
|
+
fullPath,
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
if (!shouldBypassCache(isDev)) {
|
|
462
|
+
loadingFileCache.set(cacheKey, result);
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!shouldBypassCache(isDev)) {
|
|
469
|
+
loadingFileCache.set(cacheKey, null);
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
362
474
|
function clearCascadingCaches() {
|
|
363
475
|
middlewareFileCache.clear();
|
|
364
476
|
layoutFileCache.clear();
|
|
477
|
+
errorFileCache.clear();
|
|
478
|
+
loadingFileCache.clear();
|
|
365
479
|
}
|
|
366
480
|
|
|
367
481
|
module.exports = {
|
|
@@ -374,5 +488,7 @@ module.exports = {
|
|
|
374
488
|
findCascadingLayouts,
|
|
375
489
|
executeCascadingLayoutLoads,
|
|
376
490
|
applyCascadingLayouts,
|
|
491
|
+
findNearestErrorBoundary,
|
|
492
|
+
findNearestLoadingTemplate,
|
|
377
493
|
clearCascadingCaches,
|
|
378
494
|
};
|
package/src/server.js
CHANGED
|
@@ -29,6 +29,8 @@ const { mountDiscoveredRoutes } = require('./routing/mount-discovered-routes');
|
|
|
29
29
|
const { discoverModuleDetails } = require('./modules/module-discovery');
|
|
30
30
|
const { ShutdownManager, NodeHttpAdapter } = require('../core/shutdown');
|
|
31
31
|
const { createCompressionMiddleware } = require('../core/compression');
|
|
32
|
+
const { createFlashMiddleware } = require('../core/forms/flash');
|
|
33
|
+
const { createBouncerMiddleware } = require('../core/auth/bouncer');
|
|
32
34
|
const {
|
|
33
35
|
createDevProfilerMiddleware,
|
|
34
36
|
attachProfilerToKnex,
|
|
@@ -85,12 +87,69 @@ function fastETag(body, encoding) {
|
|
|
85
87
|
* @param {Object} options - Nunjucks options
|
|
86
88
|
* @returns {nunjucks.Environment}
|
|
87
89
|
*/
|
|
90
|
+
function normalizeTemplateName(name) {
|
|
91
|
+
if (typeof name !== 'string') return name;
|
|
92
|
+
let normalized = name.replace(/\\/g, '/');
|
|
93
|
+
// Collapse any repeated views/views/... to a single views/ or clean path
|
|
94
|
+
while (/(?:^|\/)views\/(?:\.\/)?views(?:\/|$)/.test(normalized)) {
|
|
95
|
+
normalized = normalized.replace(/((?:^|\/)views)\/(?:\.\/)?views(?=\/|$)/g, '$1');
|
|
96
|
+
}
|
|
97
|
+
return normalized;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function canonicalTemplateKey(name) {
|
|
101
|
+
const norm = normalizeTemplateName(name);
|
|
102
|
+
if (typeof norm !== 'string') return String(norm);
|
|
103
|
+
// Canonical key strips leading ./ or views/ for cycle detection
|
|
104
|
+
return norm.replace(/^\.\//, '').replace(/^views\//, '');
|
|
105
|
+
}
|
|
106
|
+
|
|
88
107
|
function configureSafeNunjucks(templateDirs, options = {}) {
|
|
89
108
|
const env = nunjucks.configure(templateDirs, options);
|
|
90
109
|
const origGetTemplate = env.getTemplate.bind(env);
|
|
91
110
|
const origRender = env.render.bind(env);
|
|
92
111
|
const origRenderString = env.renderString.bind(env);
|
|
93
112
|
|
|
113
|
+
// Prevent loader.getSource and loader.resolve from producing or failing on nested views/views paths
|
|
114
|
+
if (Array.isArray(env.loaders)) {
|
|
115
|
+
for (const loader of env.loaders) {
|
|
116
|
+
if (loader && typeof loader.getSource === 'function') {
|
|
117
|
+
const origGetSource = loader.getSource.bind(loader);
|
|
118
|
+
loader.getSource = function(name) {
|
|
119
|
+
const clean = normalizeTemplateName(name);
|
|
120
|
+
let res = origGetSource(clean);
|
|
121
|
+
if (res) return res;
|
|
122
|
+
// If clean starts with views/ or ./views/, try stripping it since viewsDir is already in searchPaths
|
|
123
|
+
if (typeof clean === 'string' && /^(?:\.\/)?views\//.test(clean)) {
|
|
124
|
+
const stripped = clean.replace(/^(?:\.\/)?views\//, '');
|
|
125
|
+
res = origGetSource(stripped);
|
|
126
|
+
if (res) {
|
|
127
|
+
if (loader.pathsToNames && res.path) {
|
|
128
|
+
loader.pathsToNames[res.path] = clean;
|
|
129
|
+
}
|
|
130
|
+
return res;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (loader && typeof loader.resolve === 'function') {
|
|
137
|
+
const origResolve = loader.resolve.bind(loader);
|
|
138
|
+
loader.resolve = function(from, to) {
|
|
139
|
+
const cleanFrom = normalizeTemplateName(from);
|
|
140
|
+
const cleanTo = normalizeTemplateName(to);
|
|
141
|
+
let effectiveTo = cleanTo;
|
|
142
|
+
// If to starts with views/ and from already has views in its path, don't duplicate views
|
|
143
|
+
if (/^(?:\.\/)?views\//.test(effectiveTo) && (cleanFrom.includes('/views/') || cleanFrom.startsWith('views/') || cleanFrom.endsWith('/views'))) {
|
|
144
|
+
effectiveTo = effectiveTo.replace(/^(?:\.\/)?views\//, './');
|
|
145
|
+
}
|
|
146
|
+
const resolved = origResolve(cleanFrom, effectiveTo);
|
|
147
|
+
return normalizeTemplateName(resolved);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
94
153
|
// Production template cache to bypass loader traversal when caching is active
|
|
95
154
|
const productionTemplateCache = (!options.noCache) ? new Map() : null;
|
|
96
155
|
if (productionTemplateCache) {
|
|
@@ -103,7 +162,7 @@ function configureSafeNunjucks(templateDirs, options = {}) {
|
|
|
103
162
|
const origRoot = tmpl.rootRenderFunc;
|
|
104
163
|
if (typeof origRoot !== 'function') return;
|
|
105
164
|
|
|
106
|
-
const normalizedName =
|
|
165
|
+
const normalizedName = canonicalTemplateKey(name || tmpl.path || 'anonymous');
|
|
107
166
|
|
|
108
167
|
tmpl.rootRenderFunc = function(e, context, frame, runtime, renderCb) {
|
|
109
168
|
const store = renderStackStorage.getStore();
|
|
@@ -151,38 +210,75 @@ function configureSafeNunjucks(templateDirs, options = {}) {
|
|
|
151
210
|
eagerCompile = false;
|
|
152
211
|
}
|
|
153
212
|
|
|
213
|
+
const cleanName = normalizeTemplateName(name);
|
|
214
|
+
const cycleKey = canonicalTemplateKey(cleanName);
|
|
215
|
+
|
|
154
216
|
const store = renderStackStorage.getStore();
|
|
155
|
-
if (store &&
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const cycle = [...store.stack, normalizedName].join(' -> ');
|
|
217
|
+
if (store && cycleKey) {
|
|
218
|
+
if (store.stack.includes(cycleKey)) {
|
|
219
|
+
const cycle = [...store.stack, cycleKey].join(' -> ');
|
|
159
220
|
const err = new Error(`Circular template extension detected: ${cycle}`);
|
|
160
221
|
if (typeof cb === 'function') return cb(err);
|
|
161
222
|
throw err;
|
|
162
223
|
}
|
|
163
224
|
}
|
|
164
225
|
|
|
165
|
-
if (!cb && !parentName && productionTemplateCache && typeof
|
|
166
|
-
const cached = productionTemplateCache.get(
|
|
226
|
+
if (!cb && !parentName && productionTemplateCache && typeof cleanName === 'string') {
|
|
227
|
+
const cached = productionTemplateCache.get(cleanName);
|
|
167
228
|
if (cached) return cached;
|
|
168
229
|
}
|
|
169
230
|
|
|
170
231
|
const wrappedCb = typeof cb === 'function' ? function(err, tmpl) {
|
|
171
232
|
if (tmpl && !tmpl._safeWrapped) {
|
|
172
|
-
wrapTemplateRoot(tmpl,
|
|
233
|
+
wrapTemplateRoot(tmpl, cleanName);
|
|
173
234
|
}
|
|
174
|
-
if (productionTemplateCache && tmpl && !err && typeof
|
|
175
|
-
productionTemplateCache.set(
|
|
235
|
+
if (productionTemplateCache && tmpl && !err && typeof cleanName === 'string' && !parentName) {
|
|
236
|
+
productionTemplateCache.set(cleanName, tmpl);
|
|
176
237
|
}
|
|
177
238
|
cb(err, tmpl);
|
|
178
239
|
} : undefined;
|
|
179
240
|
|
|
180
|
-
|
|
241
|
+
// Helper to resolve template with views/ prefix fallbacks
|
|
242
|
+
function fetchTemplate(tmplName, callback) {
|
|
243
|
+
if (callback) {
|
|
244
|
+
origGetTemplate(tmplName, eagerCompile, parentName, false, (err, tmpl) => {
|
|
245
|
+
if (!err && tmpl) return callback(null, tmpl);
|
|
246
|
+
// If tmplName starts with views/, try stripped version (viewsDir already searched)
|
|
247
|
+
if (typeof tmplName === 'string' && /^(?:\.\/)?views\//.test(tmplName)) {
|
|
248
|
+
const stripped = tmplName.replace(/^(?:\.\/)?views\//, '');
|
|
249
|
+
origGetTemplate(stripped, eagerCompile, parentName, false, (err2, tmpl2) => {
|
|
250
|
+
if (!err2 && tmpl2) return callback(null, tmpl2);
|
|
251
|
+
if (ignoreMissing) return callback(null, null);
|
|
252
|
+
callback(err || err2);
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (ignoreMissing) return callback(null, null);
|
|
257
|
+
callback(err);
|
|
258
|
+
});
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
return origGetTemplate(tmplName, eagerCompile, parentName, false, undefined);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
if (typeof tmplName === 'string' && /^(?:\.\/)?views\//.test(tmplName)) {
|
|
266
|
+
const stripped = tmplName.replace(/^(?:\.\/)?views\//, '');
|
|
267
|
+
try {
|
|
268
|
+
return origGetTemplate(stripped, eagerCompile, parentName, ignoreMissing, undefined);
|
|
269
|
+
} catch (_) {}
|
|
270
|
+
}
|
|
271
|
+
if (ignoreMissing) return null;
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const res = fetchTemplate(cleanName, wrappedCb);
|
|
181
277
|
if (res && !res._safeWrapped) {
|
|
182
|
-
wrapTemplateRoot(res,
|
|
278
|
+
wrapTemplateRoot(res, cleanName);
|
|
183
279
|
}
|
|
184
|
-
if (productionTemplateCache && res && typeof
|
|
185
|
-
productionTemplateCache.set(
|
|
280
|
+
if (productionTemplateCache && res && typeof cleanName === 'string' && !parentName) {
|
|
281
|
+
productionTemplateCache.set(cleanName, res);
|
|
186
282
|
}
|
|
187
283
|
return res;
|
|
188
284
|
};
|
|
@@ -484,7 +580,16 @@ function createApp(options = {}) {
|
|
|
484
580
|
throw new Error('pagesDir is required');
|
|
485
581
|
}
|
|
486
582
|
|
|
487
|
-
const rootDir = options.rootDir ||
|
|
583
|
+
const rootDir = options.rootDir || (() => {
|
|
584
|
+
if (process.env.NODE_ENV === 'test') {
|
|
585
|
+
try {
|
|
586
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
|
|
587
|
+
if (pkg.name === 'webspresso') return process.cwd();
|
|
588
|
+
} catch (_) {}
|
|
589
|
+
return path.resolve(__dirname, '..');
|
|
590
|
+
}
|
|
591
|
+
return process.cwd();
|
|
592
|
+
})();
|
|
488
593
|
let pagesDir = initialPagesDir;
|
|
489
594
|
if (!pagesDir) {
|
|
490
595
|
if (typeof options.pages === 'string') {
|
|
@@ -564,10 +669,40 @@ function createApp(options = {}) {
|
|
|
564
669
|
}
|
|
565
670
|
}
|
|
566
671
|
|
|
567
|
-
|
|
672
|
+
// Events Subsystem (AdonisJS Events & Django Signals-inspired)
|
|
673
|
+
const { createEventDispatcher, loadListeners } = require('../core/events');
|
|
674
|
+
const eventDispatcher = options.events || createEventDispatcher({
|
|
675
|
+
queue: options.queue || null,
|
|
676
|
+
logger: logging ? console : null,
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Auto-bridge ORM lifecycle events (ModelEvents) to app.events ('model:created', etc.)
|
|
680
|
+
if (options.db) {
|
|
681
|
+
const { ModelEvents } = require('../core/orm/events');
|
|
682
|
+
eventDispatcher.bridgeOrm(ModelEvents);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Auto-discover listeners from listeners/ or options.listenersDir
|
|
686
|
+
const listenersDirOption = options.listenersDir ?? options.listeners?.dir;
|
|
687
|
+
const resolvedListenersDir = listenersDirOption
|
|
688
|
+
? path.resolve(listenersDirOption)
|
|
689
|
+
: (fs.existsSync(path.join(rootDir, 'listeners'))
|
|
690
|
+
? path.join(rootDir, 'listeners')
|
|
691
|
+
: null);
|
|
692
|
+
if (resolvedListenersDir) {
|
|
693
|
+
loadListeners(resolvedListenersDir, eventDispatcher, { logger: logging ? console : null });
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
shutdownManager.onShutdown(() => eventDispatcher.clear());
|
|
697
|
+
|
|
698
|
+
setAppContext({ db: options.db ?? null, shutdownManager, serviceRegistry, events: eventDispatcher });
|
|
568
699
|
|
|
569
700
|
const app = express();
|
|
570
701
|
app.serviceRegistry = serviceRegistry;
|
|
702
|
+
app.events = eventDispatcher;
|
|
703
|
+
if (options.queue) {
|
|
704
|
+
app.queue = options.queue;
|
|
705
|
+
}
|
|
571
706
|
|
|
572
707
|
// Unified Request Context & Lifecycle Initializer middleware
|
|
573
708
|
const { renderStream } = require('../core/ssr/stream');
|
|
@@ -596,14 +731,20 @@ function createApp(options = {}) {
|
|
|
596
731
|
req.id = reqId;
|
|
597
732
|
res.setHeader('X-Request-Id', reqId);
|
|
598
733
|
|
|
599
|
-
// 3. Request DB, Services, and Context
|
|
734
|
+
// 3. Request DB, Services, Queue, Events, and Context
|
|
600
735
|
if (options.db) {
|
|
601
736
|
req.db = options.db;
|
|
602
737
|
}
|
|
738
|
+
if (options.queue) {
|
|
739
|
+
req.queue = options.queue;
|
|
740
|
+
}
|
|
741
|
+
req.events = eventDispatcher;
|
|
603
742
|
req.context = {
|
|
604
743
|
req,
|
|
605
744
|
res,
|
|
606
745
|
db: options.db ?? null,
|
|
746
|
+
queue: options.queue ?? null,
|
|
747
|
+
events: eventDispatcher,
|
|
607
748
|
app,
|
|
608
749
|
serviceRegistry,
|
|
609
750
|
...(req.context || {}),
|
|
@@ -884,13 +1025,18 @@ function createApp(options = {}) {
|
|
|
884
1025
|
const origAppUse = app.use.bind(app);
|
|
885
1026
|
app.use = function(pathOrHandler, ...handlers) {
|
|
886
1027
|
if (typeof pathOrHandler === 'string' && handlers.length > 0) {
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1028
|
+
const flat = handlers.flat();
|
|
1029
|
+
// Filter out sub-routers (e.g. adminRouter) which must be dispatched via standard Express routing with prefix trimming
|
|
1030
|
+
const actualMiddlewares = flat.filter((h) => typeof h === 'function' && !(h.handle && Array.isArray(h.stack)));
|
|
1031
|
+
if (actualMiddlewares.length > 0) {
|
|
1032
|
+
const normPrefix = (pathOrHandler.length > 1 && pathOrHandler.endsWith('/'))
|
|
1033
|
+
? pathOrHandler.slice(0, -1)
|
|
1034
|
+
: pathOrHandler;
|
|
1035
|
+
prefixMiddlewares.push({
|
|
1036
|
+
prefix: normPrefix,
|
|
1037
|
+
handlers: actualMiddlewares.map((h) => wrapAsync(h)),
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
894
1040
|
}
|
|
895
1041
|
return origAppUse(pathOrHandler, ...handlers);
|
|
896
1042
|
};
|
|
@@ -1041,6 +1187,11 @@ function createApp(options = {}) {
|
|
|
1041
1187
|
// JSON body parser for API routes
|
|
1042
1188
|
app.use(express.json());
|
|
1043
1189
|
app.use(express.urlencoded({ extended: true }));
|
|
1190
|
+
|
|
1191
|
+
// Cookie parser & Flash messages (AdonisJS & Django-inspired old input and errors)
|
|
1192
|
+
const cookieParser = require('cookie-parser');
|
|
1193
|
+
app.use(cookieParser());
|
|
1194
|
+
app.use(createFlashMiddleware());
|
|
1044
1195
|
|
|
1045
1196
|
// Ensure default empty object for body on mutating requests if not populated
|
|
1046
1197
|
// and halt processing if request has timed out (after body parsers)
|
|
@@ -1064,6 +1215,9 @@ function createApp(options = {}) {
|
|
|
1064
1215
|
middlewares.jwt = authMiddleware.jwt;
|
|
1065
1216
|
}
|
|
1066
1217
|
|
|
1218
|
+
// Bouncer authorization middleware (AdonisJS Bouncer & Django Permissions-inspired)
|
|
1219
|
+
app.use(createBouncerMiddleware());
|
|
1220
|
+
|
|
1067
1221
|
// Under Vitest, shared API fixtures use `fixtureRequireAuth`; default no-op unless overridden.
|
|
1068
1222
|
const runsUnderVitest = process.env.VITEST === 'true' || process.env.VITEST_WORKER_ID !== undefined;
|
|
1069
1223
|
if (runsUnderVitest && middlewares.fixtureRequireAuth == null) {
|
|
@@ -1210,7 +1364,7 @@ function createApp(options = {}) {
|
|
|
1210
1364
|
}
|
|
1211
1365
|
|
|
1212
1366
|
// Register plugins (sync) — middlewares is the same object later passed to mountPages
|
|
1213
|
-
const pluginContext = { app, nunjucksEnv, options, middlewares, shutdownManager };
|
|
1367
|
+
const pluginContext = { app, nunjucksEnv, options, middlewares, shutdownManager, events: eventDispatcher };
|
|
1214
1368
|
pluginManager.registerSync(plugins, pluginContext);
|
|
1215
1369
|
|
|
1216
1370
|
// Request logging & query profiling middleware
|
|
@@ -1745,7 +1899,7 @@ function createApp(options = {}) {
|
|
|
1745
1899
|
});
|
|
1746
1900
|
};
|
|
1747
1901
|
|
|
1748
|
-
return { app, nunjucksEnv, pluginManager, authMiddleware, shutdownManager };
|
|
1902
|
+
return { app, nunjucksEnv, pluginManager, authMiddleware, shutdownManager, events: eventDispatcher };
|
|
1749
1903
|
}
|
|
1750
1904
|
|
|
1751
1905
|
// Export for use as library
|