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.
Files changed (108) hide show
  1. package/.agents/ADMIN.md +47 -4
  2. package/.agents/AGENTS.md +4 -2
  3. package/.agents/AUTH.md +309 -0
  4. package/.agents/CLI.md +128 -0
  5. package/.agents/EMAIL.md +84 -2
  6. package/.agents/ERRORS.md +59 -0
  7. package/.agents/EVENTS.md +179 -0
  8. package/.agents/ORM.md +149 -0
  9. package/.agents/PLUGINS.md +44 -7
  10. package/.agents/QUEUE.md +18 -10
  11. package/.agents/ROUTING.md +97 -1
  12. package/.agents/STREAMING.md +35 -0
  13. package/AGENTS.md +2 -0
  14. package/README.md +145 -93
  15. package/bin/commands/add-docker.js +467 -0
  16. package/bin/commands/db-makemigrations.js +83 -0
  17. package/bin/commands/deploy-check.js +339 -0
  18. package/bin/commands/list.js +96 -0
  19. package/bin/commands/make-command.js +93 -0
  20. package/bin/commands/make-event.js +80 -0
  21. package/bin/commands/make-factory.js +72 -0
  22. package/bin/commands/make-mail.js +93 -0
  23. package/bin/commands/make-policy.js +106 -0
  24. package/bin/commands/run.js +195 -0
  25. package/bin/webspresso.js +21 -0
  26. package/core/auth/bouncer.js +196 -0
  27. package/core/auth/index.js +73 -1
  28. package/core/auth/magic-link.js +170 -0
  29. package/core/auth/manager.js +343 -3
  30. package/core/auth/middleware.js +45 -6
  31. package/core/auth/session-manager.js +255 -0
  32. package/core/auth/sessions.js +351 -0
  33. package/core/auth/tokens.js +1 -0
  34. package/core/auth/totp.js +4 -1
  35. package/core/auth/user-agent.js +161 -0
  36. package/core/commands/define-command.js +48 -0
  37. package/core/commands/index.js +18 -0
  38. package/core/commands/registry.js +156 -0
  39. package/core/commands/runner.js +76 -0
  40. package/core/compression/compressible.js +1 -1
  41. package/core/compression/middleware.js +35 -251
  42. package/core/content/service.js +8 -2
  43. package/core/events/discovery.js +142 -0
  44. package/core/events/dispatcher.js +552 -0
  45. package/core/events/index.js +23 -0
  46. package/core/events/signal.js +92 -0
  47. package/core/forms/flash.js +234 -0
  48. package/core/forms/form-builder.js +214 -0
  49. package/core/forms/index.js +16 -0
  50. package/core/mail/index.js +23 -0
  51. package/core/mail/mailable.js +250 -0
  52. package/core/mail/mailer.js +184 -0
  53. package/core/mail/transports.js +238 -0
  54. package/core/orm/factory.js +250 -0
  55. package/core/orm/index.js +8 -1
  56. package/core/orm/migrations/diff.js +185 -0
  57. package/core/orm/migrations/index.js +22 -4
  58. package/core/orm/model.js +55 -1
  59. package/core/orm/repository.js +283 -13
  60. package/core/orm/scopes.js +3 -2
  61. package/core/orm/types.js +6 -1
  62. package/core/queue/cron.js +252 -39
  63. package/core/queue/manager.js +20 -2
  64. package/core/queue/scheduler.js +115 -59
  65. package/core/ssr/stream.js +62 -2
  66. package/index.d.ts +540 -14
  67. package/index.js +44 -0
  68. package/package.json +11 -5
  69. package/plugins/admin-panel/api.js +509 -3
  70. package/plugins/admin-panel/index.js +17 -0
  71. package/plugins/admin-panel/modules/admin-users.js +1 -1
  72. package/plugins/audit-log/admin-component.js +54 -54
  73. package/plugins/audit-log/index.js +2 -2
  74. package/plugins/content/client/inline-edit.js +2 -46
  75. package/plugins/content/models/content-entry.js +4 -3
  76. package/plugins/content/models/content-type.js +4 -3
  77. package/plugins/cors.js +15 -4
  78. package/plugins/email/auth-bridge.js +66 -0
  79. package/plugins/email/bundled-templates.js +15 -0
  80. package/plugins/email/index.js +48 -0
  81. package/plugins/email/preview-handler.js +555 -0
  82. package/plugins/email/service.js +23 -12
  83. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  84. package/plugins/email/transport.js +30 -11
  85. package/plugins/file-manager/admin-component.js +49 -49
  86. package/plugins/file-manager/index.js +4 -4
  87. package/plugins/index.js +2 -0
  88. package/plugins/redirect/index.js +1 -1
  89. package/plugins/webhooks/admin-component.js +608 -0
  90. package/plugins/webhooks/api-handlers.js +237 -0
  91. package/plugins/webhooks/crypto.js +144 -0
  92. package/plugins/webhooks/dispatcher.js +708 -0
  93. package/plugins/webhooks/index.js +263 -0
  94. package/plugins/webhooks/matcher.js +113 -0
  95. package/plugins/webhooks/migration-template.js +75 -0
  96. package/plugins/webhooks/models.js +98 -0
  97. package/plugins/webhooks/sender.js +176 -0
  98. package/src/api/api-loader.js +1 -1
  99. package/src/app-context.js +48 -6
  100. package/src/bundle/index.js +2 -1
  101. package/src/dev-reload/index.js +9 -1
  102. package/src/discovery/scan-routes.js +10 -1
  103. package/src/file-router.js +173 -4
  104. package/src/helpers.js +150 -1
  105. package/src/njk-frontmatter.js +3 -0
  106. package/src/pages/page-loader.js +1 -1
  107. package/src/routing/cascading.js +120 -4
  108. 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().replace(/^\/+/, '').replace(/\/+$/, '');
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')
@@ -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
  }
@@ -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
@@ -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 = String(name || tmpl.path || 'anonymous');
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 && name) {
156
- const normalizedName = String(name);
157
- if (store.stack.includes(normalizedName)) {
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 name === 'string') {
166
- const cached = productionTemplateCache.get(name);
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, name);
233
+ wrapTemplateRoot(tmpl, cleanName);
173
234
  }
174
- if (productionTemplateCache && tmpl && !err && typeof name === 'string' && !parentName) {
175
- productionTemplateCache.set(name, tmpl);
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
- const res = origGetTemplate(name, eagerCompile, parentName, ignoreMissing, wrappedCb);
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, name);
278
+ wrapTemplateRoot(res, cleanName);
183
279
  }
184
- if (productionTemplateCache && res && typeof name === 'string' && !parentName) {
185
- productionTemplateCache.set(name, res);
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 || process.cwd();
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
- setAppContext({ db: options.db ?? null, shutdownManager, serviceRegistry });
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 normPrefix = (pathOrHandler.length > 1 && pathOrHandler.endsWith('/'))
888
- ? pathOrHandler.slice(0, -1)
889
- : pathOrHandler;
890
- prefixMiddlewares.push({
891
- prefix: normPrefix,
892
- handlers: handlers.flat().map((h) => wrapAsync(h)),
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