webspresso 0.0.97 → 0.0.98

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 (100) hide show
  1. package/.agents/ADMIN.md +153 -5
  2. package/.agents/ARCHITECTURE.md +10 -0
  3. package/.agents/CLI.md +195 -1
  4. package/.agents/PLUGINS.md +153 -9
  5. package/.agents/QUEUE.md +43 -0
  6. package/bin/commands/add-esbuild.js +80 -0
  7. package/bin/commands/add-tailwind.js +76 -31
  8. package/bin/commands/analytics-prune.js +57 -0
  9. package/bin/commands/api.js +207 -15
  10. package/bin/commands/build.js +99 -0
  11. package/bin/commands/dev.js +64 -35
  12. package/bin/commands/doctor.js +26 -0
  13. package/bin/commands/email-prune.js +47 -0
  14. package/bin/commands/make-job.js +86 -0
  15. package/bin/commands/make-model.js +250 -0
  16. package/bin/commands/new.js +45 -28
  17. package/bin/commands/openapi.js +102 -0
  18. package/bin/commands/page.js +6 -3
  19. package/bin/commands/queue-prune.js +59 -0
  20. package/bin/commands/routes.js +52 -0
  21. package/bin/commands/service.js +6 -3
  22. package/bin/commands/typegen.js +106 -0
  23. package/bin/commands/upload-prune.js +79 -0
  24. package/bin/webspresso.js +22 -0
  25. package/core/applySchema.js +13 -6
  26. package/core/auth/hash.js +21 -12
  27. package/core/compression/middleware.js +23 -17
  28. package/core/openapi/build-from-api-routes.js +160 -29
  29. package/core/orm/cache/fingerprint.js +15 -1
  30. package/core/orm/cache/memory-provider.js +27 -2
  31. package/core/orm/index.js +12 -7
  32. package/core/orm/model.js +49 -7
  33. package/core/queue/adapters/database.js +94 -0
  34. package/core/queue/adapters/memory.js +62 -0
  35. package/core/queue/adapters/redis.js +80 -0
  36. package/core/queue/manager.js +123 -0
  37. package/core/ssr/stream.js +11 -0
  38. package/index.d.ts +660 -10
  39. package/index.js +44 -96
  40. package/package.json +1 -1
  41. package/plugins/admin-panel/api.js +279 -17
  42. package/plugins/admin-panel/app.js +114 -22
  43. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  44. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  46. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  47. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  48. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  49. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  50. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  51. package/plugins/admin-panel/core/admin-module.js +13 -7
  52. package/plugins/admin-panel/core/api-extensions.js +30 -2
  53. package/plugins/admin-panel/core/registry.js +96 -16
  54. package/plugins/admin-panel/index.js +96 -0
  55. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  56. package/plugins/admin-panel/modules/admin-users.js +16 -1
  57. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  58. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  59. package/plugins/admin-panel/modules/dashboard.js +283 -0
  60. package/plugins/admin-panel/modules/menu.js +56 -18
  61. package/plugins/admin-panel/modules/user-management.js +24 -6
  62. package/plugins/audit-log/admin-component.js +11 -0
  63. package/plugins/audit-log/api-handlers.js +34 -0
  64. package/plugins/audit-log/index.js +129 -3
  65. package/plugins/audit-log/purge.js +60 -10
  66. package/plugins/dashboard/app.js +152 -27
  67. package/plugins/dashboard/index.js +143 -39
  68. package/plugins/dashboard/styles.js +78 -0
  69. package/plugins/data-exchange/export-xlsx.js +9 -2
  70. package/plugins/data-exchange/import.js +9 -2
  71. package/plugins/data-exchange/index.js +6 -3
  72. package/plugins/email/index.js +132 -2
  73. package/plugins/email/purge.js +60 -10
  74. package/plugins/index.js +4 -0
  75. package/plugins/orm-cache-admin/index.js +4 -2
  76. package/plugins/queue/index.js +11 -0
  77. package/plugins/schema-explorer/admin-component.js +714 -0
  78. package/plugins/schema-explorer/relations-graph.js +205 -0
  79. package/plugins/schema-explorer.js +248 -69
  80. package/plugins/site-analytics/admin-component.js +23 -0
  81. package/plugins/site-analytics/api-handlers.js +42 -0
  82. package/plugins/site-analytics/index.js +147 -2
  83. package/plugins/site-analytics/purge.js +168 -0
  84. package/plugins/swagger.js +261 -13
  85. package/plugins/upload/index.js +26 -0
  86. package/plugins/upload/orphan-pruner.js +292 -0
  87. package/src/api/api-loader.js +3 -1
  88. package/src/bundle/index.js +517 -0
  89. package/src/bundle/manifest.js +165 -0
  90. package/src/dev/banner.js +296 -0
  91. package/src/dev/colors.js +70 -0
  92. package/src/dev/index.js +62 -0
  93. package/src/dev/profiler.js +354 -0
  94. package/src/dev/typegen.js +497 -0
  95. package/src/dev-reload/index.js +611 -0
  96. package/src/file-router.js +210 -62
  97. package/src/helpers.js +958 -98
  98. package/src/njk-frontmatter.js +12 -0
  99. package/src/pages/page-loader.js +61 -11
  100. package/src/server.js +629 -63
package/src/server.js CHANGED
@@ -6,6 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const crypto = require('crypto');
9
+ const zlib = require('zlib');
9
10
  const { AsyncLocalStorage } = require('async_hooks');
10
11
  const express = require('express');
11
12
  const helmet = require('helmet');
@@ -17,6 +18,9 @@ const { mountClientRuntime } = require('./client-runtime/mount');
17
18
  const { resolveClientRuntime } = require('./client-runtime/resolve');
18
19
  const { mountPages, detectLocale, loadI18n, createTranslator } = require('./file-router');
19
20
  const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
21
+ const { createDevReloadHub } = require('./dev-reload');
22
+ const { createDevBundler } = require('./bundle');
23
+ const { registerClearHook } = require('./njk-frontmatter');
20
24
  const { createPluginManager } = require('./plugin-manager');
21
25
  const { createServiceRegistry } = require('./services');
22
26
  const { scanRoutes } = require('./discovery/scan-routes');
@@ -25,6 +29,12 @@ const { mountDiscoveredRoutes } = require('./routing/mount-discovered-routes');
25
29
  const { discoverModuleDetails } = require('./modules/module-discovery');
26
30
  const { ShutdownManager, NodeHttpAdapter } = require('../core/shutdown');
27
31
  const { createCompressionMiddleware } = require('../core/compression');
32
+ const {
33
+ createDevProfilerMiddleware,
34
+ attachProfilerToKnex,
35
+ formatDevBanner,
36
+ printDevBanner,
37
+ } = require('./dev');
28
38
  const {
29
39
  WebspressoError,
30
40
  HttpError,
@@ -40,6 +50,35 @@ const {
40
50
  // Async storage for tracking template render call stacks (circular extends guard)
41
51
  const renderStackStorage = new AsyncLocalStorage();
42
52
 
53
+ /**
54
+ * Fast hardware-accelerated Weak ETag generator using native zlib.crc32
55
+ * Up to 13x faster than Express default SHA-1, zero external dependencies.
56
+ * Compliant with RFC 7232 Section 2.3 (Weak Entity Tag).
57
+ * Format: W/"<len-hex>-<crc32-hex>"
58
+ *
59
+ * @param {Buffer|string} body - Response body
60
+ * @param {string} [encoding] - String encoding if body is string
61
+ * @returns {string} RFC 7232 formatted Weak ETag
62
+ */
63
+ function fastETag(body, encoding) {
64
+ if (!body || body.length === 0) {
65
+ return 'W/"0-0"';
66
+ }
67
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, encoding || 'utf8');
68
+ if (buf.length === 0) {
69
+ return 'W/"0-0"';
70
+ }
71
+ if (typeof zlib.crc32 === 'function') {
72
+ return `W/"${buf.length.toString(16)}-${zlib.crc32(buf).toString(16)}"`;
73
+ }
74
+ // Graceful fallback for environments without zlib.crc32
75
+ let h1 = 0x811c9dc5;
76
+ for (let i = 0; i < buf.length; i++) {
77
+ h1 = Math.imul(h1 ^ buf[i], 0x01000193);
78
+ }
79
+ return `W/"${buf.length.toString(16)}-${(h1 >>> 0).toString(16)}"`;
80
+ }
81
+
43
82
  /**
44
83
  * Configure Nunjucks environment with circular extends guard and loader protection
45
84
  * @param {string|string[]} templateDirs - Template directory paths
@@ -52,14 +91,21 @@ function configureSafeNunjucks(templateDirs, options = {}) {
52
91
  const origRender = env.render.bind(env);
53
92
  const origRenderString = env.renderString.bind(env);
54
93
 
94
+ // Production template cache to bypass loader traversal when caching is active
95
+ const productionTemplateCache = (!options.noCache) ? new Map() : null;
96
+ if (productionTemplateCache) {
97
+ registerClearHook(() => productionTemplateCache.clear());
98
+ }
99
+
55
100
  function wrapTemplateRoot(tmpl, name) {
56
101
  if (!tmpl || tmpl._safeWrapped) return;
57
102
  tmpl._safeWrapped = true;
58
103
  const origRoot = tmpl.rootRenderFunc;
59
104
  if (typeof origRoot !== 'function') return;
60
105
 
106
+ const normalizedName = String(name || tmpl.path || 'anonymous');
107
+
61
108
  tmpl.rootRenderFunc = function(e, context, frame, runtime, renderCb) {
62
- const normalizedName = String(name || tmpl.path || 'anonymous');
63
109
  const store = renderStackStorage.getStore();
64
110
  if (store) {
65
111
  store.stack.push(normalizedName);
@@ -69,8 +115,12 @@ function configureSafeNunjucks(templateDirs, options = {}) {
69
115
  if (!finished) {
70
116
  finished = true;
71
117
  if (store) {
72
- const idx = store.stack.lastIndexOf(normalizedName);
73
- if (idx !== -1) store.stack.splice(idx, 1);
118
+ if (store.stack[store.stack.length - 1] === normalizedName) {
119
+ store.stack.pop();
120
+ } else {
121
+ const idx = store.stack.lastIndexOf(normalizedName);
122
+ if (idx !== -1) store.stack.splice(idx, 1);
123
+ }
74
124
  }
75
125
  }
76
126
  renderCb(err, out);
@@ -79,8 +129,12 @@ function configureSafeNunjucks(templateDirs, options = {}) {
79
129
  return origRoot.call(this, e, context, frame, runtime, done);
80
130
  } catch (ex) {
81
131
  if (store) {
82
- const idx = store.stack.lastIndexOf(normalizedName);
83
- if (idx !== -1) store.stack.splice(idx, 1);
132
+ if (store.stack[store.stack.length - 1] === normalizedName) {
133
+ store.stack.pop();
134
+ } else {
135
+ const idx = store.stack.lastIndexOf(normalizedName);
136
+ if (idx !== -1) store.stack.splice(idx, 1);
137
+ }
84
138
  }
85
139
  throw ex;
86
140
  }
@@ -108,17 +162,28 @@ function configureSafeNunjucks(templateDirs, options = {}) {
108
162
  }
109
163
  }
110
164
 
165
+ if (!cb && !parentName && productionTemplateCache && typeof name === 'string') {
166
+ const cached = productionTemplateCache.get(name);
167
+ if (cached) return cached;
168
+ }
169
+
111
170
  const wrappedCb = typeof cb === 'function' ? function(err, tmpl) {
112
- if (tmpl) {
171
+ if (tmpl && !tmpl._safeWrapped) {
113
172
  wrapTemplateRoot(tmpl, name);
114
173
  }
174
+ if (productionTemplateCache && tmpl && !err && typeof name === 'string' && !parentName) {
175
+ productionTemplateCache.set(name, tmpl);
176
+ }
115
177
  cb(err, tmpl);
116
178
  } : undefined;
117
179
 
118
180
  const res = origGetTemplate(name, eagerCompile, parentName, ignoreMissing, wrappedCb);
119
- if (res) {
181
+ if (res && !res._safeWrapped) {
120
182
  wrapTemplateRoot(res, name);
121
183
  }
184
+ if (productionTemplateCache && res && typeof name === 'string' && !parentName) {
185
+ productionTemplateCache.set(name, res);
186
+ }
122
187
  return res;
123
188
  };
124
189
 
@@ -407,7 +472,13 @@ function createApp(options = {}) {
407
472
  timeout: timeoutConfig = '30s',
408
473
  auth: authManager = null,
409
474
  setupRoutes,
475
+ dev: devOpts = {},
410
476
  } = options;
477
+
478
+ // Auto-instrument Knex queries for dev profiler
479
+ if (options.db && options.db.knex) {
480
+ attachProfilerToKnex(options.db.knex);
481
+ }
411
482
 
412
483
  if (!initialPagesDir && !options.pages && !options.api && !options.modules && !options.routes && !options.rootDir) {
413
484
  throw new Error('pagesDir is required');
@@ -433,8 +504,11 @@ function createApp(options = {}) {
433
504
  const pluginManager = createPluginManager();
434
505
 
435
506
  // Configure asset manager
507
+ const projectBase = publicDir ? path.dirname(path.resolve(publicDir)) : process.cwd();
436
508
  configureAssets({
437
509
  publicDir: publicDir || 'public',
510
+ srcDir: options.srcDir || path.join(projectBase, 'src'),
511
+ pagesDir: pagesDir || 'pages',
438
512
  ...assetsConfig
439
513
  });
440
514
 
@@ -495,16 +569,33 @@ function createApp(options = {}) {
495
569
  const app = express();
496
570
  app.serviceRegistry = serviceRegistry;
497
571
 
498
- // Correlation Request ID middleware
572
+ // Unified Request Context & Lifecycle Initializer middleware
573
+ const { renderStream } = require('../core/ssr/stream');
499
574
  app.use((req, res, next) => {
575
+ // 1. Request draining check during graceful shutdown
576
+ if (shutdownManager.isShuttingDown) {
577
+ res.set('Connection', 'close');
578
+ if (!res.headersSent) {
579
+ if (preferJsonErrorResponse(req)) {
580
+ return res.status(503).json({ error: 'Service Unavailable', message: 'Server is shutting down', status: 503 });
581
+ }
582
+ return res.status(503).send('Server is shutting down');
583
+ }
584
+ res.on('finish', () => {
585
+ try {
586
+ if (req.socket && !req.socket.destroyed) {
587
+ req.socket.end();
588
+ }
589
+ } catch (e) {}
590
+ });
591
+ }
592
+
593
+ // 2. Correlation Request ID
500
594
  const reqId = (req.headers && req.headers['x-request-id']) ? req.headers['x-request-id'] : crypto.randomUUID();
501
595
  req.id = reqId;
502
596
  res.setHeader('X-Request-Id', reqId);
503
- next();
504
- });
505
597
 
506
- // Request context & services caller middleware
507
- app.use((req, res, next) => {
598
+ // 3. Request DB, Services, and Context
508
599
  if (options.db) {
509
600
  req.db = options.db;
510
601
  }
@@ -518,29 +609,40 @@ function createApp(options = {}) {
518
609
  };
519
610
  req.service = (name, input, opts) =>
520
611
  serviceRegistry.call(name, input, req.context, opts);
521
- next();
522
- });
523
612
 
524
- // SSR Streaming Response Helper
525
- const { renderStream } = require('../core/ssr/stream');
526
- app.use((req, res, next) => {
613
+ // 4. SSR Streaming Response Helper
527
614
  res.renderStream = (templatePath, context = {}, renderOptions = {}) => {
528
615
  return renderStream(res, templatePath, context, {
529
616
  env: nunjucksEnv,
530
617
  ...renderOptions,
531
618
  });
532
619
  };
620
+
533
621
  next();
534
622
  });
535
623
 
536
624
  // Async handler wrapper helper for automatic promise rejection handling
537
625
  function wrapAsync(fn) {
538
626
  if (typeof fn !== 'function') return fn;
627
+ if (fn._isWrappedAsync) return fn;
628
+ let wrapped;
539
629
  if (fn.length === 4) {
540
- return function(err, req, res, next) {
630
+ wrapped = function(err, req, res, next) {
541
631
  try {
542
632
  const ret = fn.call(this, err, req, res, next);
543
- if (ret && typeof ret.catch === 'function') {
633
+ if (ret && ret.then) {
634
+ ret.catch(next);
635
+ }
636
+ return ret;
637
+ } catch (syncErr) {
638
+ return next(syncErr);
639
+ }
640
+ };
641
+ } else {
642
+ wrapped = function(req, res, next) {
643
+ try {
644
+ const ret = fn.call(this, req, res, next);
645
+ if (ret && ret.then) {
544
646
  ret.catch(next);
545
647
  }
546
648
  return ret;
@@ -549,51 +651,243 @@ function createApp(options = {}) {
549
651
  }
550
652
  };
551
653
  }
552
- return function(req, res, next) {
654
+ wrapped._isWrappedAsync = true;
655
+ return wrapped;
656
+ }
657
+
658
+ function dispatchFastPath(handlers, req, res, next) {
659
+ let idx = 0;
660
+ function runNext(err) {
661
+ if (err) return next(err);
662
+ if (idx >= handlers.length) return next();
663
+ const fn = handlers[idx++];
553
664
  try {
554
- const ret = fn.call(this, req, res, next);
555
- if (ret && typeof ret.catch === 'function') {
556
- ret.catch(next);
665
+ const ret = fn(req, res, runNext);
666
+ if (ret && typeof ret.then === 'function') {
667
+ ret.catch(runNext);
557
668
  }
558
- return ret;
559
- } catch (syncErr) {
560
- return next(syncErr);
669
+ } catch (e) {
670
+ runNext(e);
561
671
  }
672
+ }
673
+ runNext();
674
+ }
675
+
676
+ const fastPathStaticByMethod = {
677
+ GET: new Map(),
678
+ POST: new Map(),
679
+ PUT: new Map(),
680
+ DELETE: new Map(),
681
+ PATCH: new Map(),
682
+ HEAD: new Map(),
683
+ OPTIONS: new Map(),
684
+ };
685
+ const fastPathStaticMap = new Map();
686
+ app.fastPathStaticMap = fastPathStaticMap;
687
+ app.fastPathStaticByMethod = fastPathStaticByMethod;
688
+
689
+ // Dynamic route registration list and LRU lookup cache
690
+ const dynamicRouteList = [];
691
+ const dynamicRouteMap = new Map();
692
+ const dynamicRouteLookupCache = new Map();
693
+ const prefixMiddlewares = [];
694
+ const MAX_DYNAMIC_CACHE_SIZE = 2000;
695
+ const NOT_FOUND_SENTINEL = Symbol('NOT_FOUND');
696
+
697
+ app.dynamicRouteList = dynamicRouteList;
698
+ app.dynamicRouteLookupCache = dynamicRouteLookupCache;
699
+
700
+ function getDynamicCache(key) {
701
+ const val = dynamicRouteLookupCache.get(key);
702
+ if (!val) return undefined;
703
+ dynamicRouteLookupCache.delete(key);
704
+ dynamicRouteLookupCache.set(key, val);
705
+ return val;
706
+ }
707
+
708
+ function setDynamicCache(key, val) {
709
+ if (dynamicRouteLookupCache.size >= MAX_DYNAMIC_CACHE_SIZE) {
710
+ const oldestKey = dynamicRouteLookupCache.keys().next().value;
711
+ dynamicRouteLookupCache.delete(oldestKey);
712
+ }
713
+ dynamicRouteLookupCache.set(key, val);
714
+ }
715
+
716
+ function extractRouteParams(paramNames, match) {
717
+ const params = {};
718
+ for (let i = 0; i < paramNames.length; i++) {
719
+ const val = match[i + 1];
720
+ if (val !== undefined) {
721
+ try {
722
+ params[paramNames[i]] = decodeURIComponent(val);
723
+ } catch (_) {
724
+ params[paramNames[i]] = val;
725
+ }
726
+ }
727
+ }
728
+ return params;
729
+ }
730
+
731
+ function compileRoutePattern(pattern) {
732
+ if (typeof pattern !== 'string') return null;
733
+ if (pattern === '*' || pattern === '/*' || pattern === '') return null;
734
+ if (pattern.includes('(') || pattern.includes(')')) return null;
735
+
736
+ const paramNames = [];
737
+ const parts = pattern.split('/');
738
+ let regexStr = '^';
739
+ let hasWildcard = false;
740
+
741
+ for (let i = 1; i < parts.length; i++) {
742
+ const part = parts[i];
743
+ if (!part) continue;
744
+ regexStr += '\\/';
745
+ if (part.startsWith(':')) {
746
+ const isOptional = part.endsWith('?');
747
+ const name = isOptional ? part.slice(1, -1) : part.slice(1);
748
+ paramNames.push(name);
749
+ regexStr += isOptional ? '([^/]+)?' : '([^/]+)';
750
+ } else if (part.startsWith('*')) {
751
+ const name = part.slice(1) || '0';
752
+ paramNames.push(name);
753
+ regexStr += '(.*)';
754
+ hasWildcard = true;
755
+ } else {
756
+ regexStr += part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
757
+ }
758
+ }
759
+
760
+ if (regexStr === '^') {
761
+ regexStr = '^\\/?$';
762
+ } else {
763
+ regexStr += '\\/?$';
764
+ }
765
+
766
+ return {
767
+ pattern,
768
+ regex: new RegExp(regexStr),
769
+ paramNames,
770
+ hasWildcard,
562
771
  };
563
772
  }
564
773
 
565
- // Wrap routing methods on app to catch uncaught async errors
774
+ // Wrap routing methods on app to catch uncaught async errors and register fast-path static and dynamic routes
566
775
  const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'all'];
567
776
  for (const method of HTTP_METHODS) {
568
777
  const origMethod = app[method];
569
778
  if (typeof origMethod === 'function') {
570
779
  app[method] = function(path, ...handlers) {
780
+ if (handlers.length === 0) {
781
+ return origMethod.call(this, path);
782
+ }
571
783
  const wrappedHandlers = handlers.flat().map((h) => wrapAsync(h));
784
+
785
+ const normPath = (typeof path === 'string' && path.length > 1 && path.endsWith('/'))
786
+ ? path.slice(0, -1)
787
+ : path;
788
+
789
+ // Fast-path static registration for literal string paths without dynamic parameters or regex
790
+ if (
791
+ typeof normPath === 'string' &&
792
+ normPath.length > 0 &&
793
+ !normPath.includes(':') &&
794
+ !normPath.includes('*') &&
795
+ !normPath.includes('(') &&
796
+ !normPath.includes(')') &&
797
+ !normPath.includes('?')
798
+ ) {
799
+ const uMethod = method.toUpperCase();
800
+ const routeInfo = { path: normPath, methods: { [method.toLowerCase()]: true } };
801
+ const registerStatic = (m) => {
802
+ const key = `${m} ${normPath}`;
803
+ let methodMap = fastPathStaticByMethod[m];
804
+ if (!methodMap) {
805
+ methodMap = new Map();
806
+ fastPathStaticByMethod[m] = methodMap;
807
+ }
808
+ const existing = methodMap.get(normPath);
809
+ if (existing) {
810
+ existing.handlers.push(...wrappedHandlers);
811
+ } else {
812
+ const entry = {
813
+ path: normPath,
814
+ handlers: [...wrappedHandlers],
815
+ routeInfo,
816
+ };
817
+ methodMap.set(normPath, entry);
818
+ fastPathStaticMap.set(key, entry);
819
+ }
820
+ };
821
+
822
+ if (uMethod === 'ALL') {
823
+ for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']) {
824
+ registerStatic(m);
825
+ }
826
+ } else {
827
+ registerStatic(uMethod);
828
+ }
829
+ dynamicRouteLookupCache.clear();
830
+ } else if (
831
+ typeof path === 'string' &&
832
+ path.length > 0 &&
833
+ (path.includes(':') || path.includes('*')) &&
834
+ path !== '*' &&
835
+ path !== '/*' &&
836
+ !path.includes('(') &&
837
+ !path.includes(')')
838
+ ) {
839
+ const compiled = compileRoutePattern(path);
840
+ if (compiled) {
841
+ const uMethod = method.toUpperCase();
842
+ const routeInfo = { path, methods: { [method.toLowerCase()]: true } };
843
+ const registerDynamic = (m) => {
844
+ const key = `${m} ${path}`;
845
+ const existing = dynamicRouteMap.get(key);
846
+ if (existing) {
847
+ existing.handlers.push(...wrappedHandlers);
848
+ } else {
849
+ const entry = {
850
+ method: m,
851
+ path,
852
+ compiled,
853
+ handlers: [...wrappedHandlers],
854
+ routeInfo,
855
+ };
856
+ dynamicRouteMap.set(key, entry);
857
+ dynamicRouteList.push(entry);
858
+ }
859
+ };
860
+
861
+ if (uMethod === 'ALL') {
862
+ for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']) {
863
+ registerDynamic(m);
864
+ }
865
+ } else {
866
+ registerDynamic(uMethod);
867
+ }
868
+ dynamicRouteLookupCache.clear();
869
+ }
870
+ }
871
+
572
872
  return origMethod.call(this, path, ...wrappedHandlers);
573
873
  };
574
874
  }
575
875
  }
576
876
 
577
- // Request draining check during graceful shutdown (zero overhead during normal operation)
578
- app.use((req, res, next) => {
579
- if (shutdownManager.isShuttingDown) {
580
- res.set('Connection', 'close');
581
- if (!res.headersSent) {
582
- if (preferJsonErrorResponse(req)) {
583
- return res.status(503).json({ error: 'Service Unavailable', message: 'Server is shutting down', status: 503 });
584
- }
585
- return res.status(503).send('Server is shutting down');
586
- }
587
- res.on('finish', () => {
588
- try {
589
- if (req.socket && !req.socket.destroyed) {
590
- req.socket.end();
591
- }
592
- } catch (e) {}
877
+ // Wrap app.use to track path-prefixed middlewares for fast-path dispatcher execution
878
+ const origAppUse = app.use.bind(app);
879
+ app.use = function(pathOrHandler, ...handlers) {
880
+ if (typeof pathOrHandler === 'string' && handlers.length > 0) {
881
+ const normPrefix = (pathOrHandler.length > 1 && pathOrHandler.endsWith('/'))
882
+ ? pathOrHandler.slice(0, -1)
883
+ : pathOrHandler;
884
+ prefixMiddlewares.push({
885
+ prefix: normPrefix,
886
+ handlers: handlers.flat().map((h) => wrapAsync(h)),
593
887
  });
594
888
  }
595
- next();
596
- });
889
+ return origAppUse(pathOrHandler, ...handlers);
890
+ };
597
891
 
598
892
  // Security headers with Helmet
599
893
  if (helmetConfig !== false) {
@@ -638,7 +932,63 @@ function createApp(options = {}) {
638
932
  }
639
933
  }
640
934
 
641
- app.use(helmet(finalConfig));
935
+ const helmetMw = helmet(finalConfig);
936
+
937
+ // Check if configuration uses purely static directives (no per-request dynamic functions)
938
+ let isDynamicCsp = false;
939
+ if (finalConfig.contentSecurityPolicy && finalConfig.contentSecurityPolicy.directives) {
940
+ for (const val of Object.values(finalConfig.contentSecurityPolicy.directives)) {
941
+ if (typeof val === 'function') {
942
+ isDynamicCsp = true;
943
+ break;
944
+ }
945
+ if (Array.isArray(val) && val.some((item) => typeof item === 'function')) {
946
+ isDynamicCsp = true;
947
+ break;
948
+ }
949
+ }
950
+ }
951
+
952
+ if (!isDynamicCsp) {
953
+ const staticHeaders = {};
954
+ let removePoweredBy = false;
955
+ const dummyRes = {
956
+ setHeader(name, value) {
957
+ staticHeaders[name] = value;
958
+ },
959
+ removeHeader(name) {
960
+ if (name && name.toLowerCase() === 'x-powered-by') {
961
+ removePoweredBy = true;
962
+ }
963
+ },
964
+ };
965
+
966
+ try {
967
+ helmetMw({ headers: {} }, dummyRes, () => {});
968
+ } catch (_) {
969
+ // Fallback to standard helmet middleware if mock invocation fails
970
+ }
971
+
972
+ const headerEntries = Object.entries(staticHeaders);
973
+ if (headerEntries.length > 0) {
974
+ if (removePoweredBy) {
975
+ app.disable('x-powered-by');
976
+ }
977
+ app.use((req, res, next) => {
978
+ for (let i = 0; i < headerEntries.length; i++) {
979
+ res.setHeader(headerEntries[i][0], headerEntries[i][1]);
980
+ }
981
+ if (removePoweredBy) {
982
+ res.removeHeader('X-Powered-By');
983
+ }
984
+ next();
985
+ });
986
+ } else {
987
+ app.use(helmetMw);
988
+ }
989
+ } else {
990
+ app.use(helmetMw);
991
+ }
642
992
  }
643
993
 
644
994
  // HTTP Response Compression
@@ -668,23 +1018,34 @@ function createApp(options = {}) {
668
1018
  // Ensure extended query parser for nested filter/sort query parameters (?filter[field]=val)
669
1019
  app.set('query parser', 'extended');
670
1020
 
1021
+ // Fast hardware-accelerated Weak ETag (CRC32 + Hex length) as default
1022
+ // Configurable via options.etag or options.server.etag
1023
+ const etagSetting = options.etag !== undefined
1024
+ ? options.etag
1025
+ : (options.server?.etag !== undefined ? options.server.etag : 'fast');
1026
+
1027
+ if (etagSetting === false || etagSetting === null) {
1028
+ app.set('etag', false);
1029
+ } else if (etagSetting === 'fast' || etagSetting === true) {
1030
+ app.set('etag', fastETag);
1031
+ } else {
1032
+ app.set('etag', etagSetting);
1033
+ }
1034
+
671
1035
  // JSON body parser for API routes
672
1036
  app.use(express.json());
673
1037
  app.use(express.urlencoded({ extended: true }));
674
1038
 
675
1039
  // Ensure default empty object for body on mutating requests if not populated
1040
+ // and halt processing if request has timed out (after body parsers)
676
1041
  app.use((req, res, next) => {
1042
+ if (timeoutConfig !== false && req.timedout) return;
677
1043
  if (req.body === undefined && (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH')) {
678
1044
  req.body = {};
679
1045
  }
680
1046
  next();
681
1047
  });
682
1048
 
683
- // Halt processing if request has timed out (after body parsers)
684
- if (timeoutConfig !== false) {
685
- app.use(haltOnTimedout);
686
- }
687
-
688
1049
  // Authentication middleware (if auth manager provided)
689
1050
  let authMiddleware = null;
690
1051
  if (authManager) {
@@ -713,18 +1074,70 @@ function createApp(options = {}) {
713
1074
 
714
1075
  mountClientRuntime(app, clientRuntime);
715
1076
 
1077
+ // Development Hot / Live Reload Hub (SSE & CSS hot swap & Template Live Reload)
1078
+ const devReloadOption = options.devReload;
1079
+ const isDevReloadExplicit = devReloadOption === true || (typeof devReloadOption === 'object' && devReloadOption.enabled === true);
1080
+ const isDevReloadDisabled = devReloadOption === false || (typeof devReloadOption === 'object' && devReloadOption.enabled === false);
1081
+ const devReloadEnabled = isDevReloadExplicit || (!isDevReloadDisabled && isDev && !isTest);
1082
+
1083
+ const devReloadHub = createDevReloadHub({
1084
+ app,
1085
+ publicDir,
1086
+ pagesDir,
1087
+ viewsDir,
1088
+ options: typeof devReloadOption === 'object' ? devReloadOption : {},
1089
+ enabled: devReloadEnabled,
1090
+ logger: logging ? console : null,
1091
+ });
1092
+
1093
+ if (devReloadHub) {
1094
+ app.devReloadHub = devReloadHub;
1095
+ app.broadcastDevReload = (type, payload) => devReloadHub.broadcast(type, payload);
1096
+ if (shutdownManager) {
1097
+ shutdownManager.registerDisposer('devReload', () => devReloadHub.close());
1098
+ }
1099
+ }
1100
+
1101
+ // Client-side JS incremental bundler (esbuild context() API)
1102
+ const bundleOption = options.bundle;
1103
+ const isBundleExplicit = bundleOption === true || (typeof bundleOption === 'object' && bundleOption.enabled !== false);
1104
+ const isBundleDisabled = bundleOption === false;
1105
+
1106
+ if (isBundleExplicit && !isBundleDisabled) {
1107
+ const bundlerOpts = typeof bundleOption === 'object' ? { ...bundleOption } : {};
1108
+ if (!bundlerOpts.cwd) bundlerOpts.cwd = rootDir;
1109
+ const devBundler = createDevBundler(bundlerOpts, devReloadHub);
1110
+ app.devBundler = devBundler;
1111
+
1112
+ if (devBundler.isAvailable() && bundlerOpts.watch !== false && isDev && !isTest) {
1113
+ devBundler.startWatch().catch((err) => {
1114
+ if (logging) console.error('[Webspresso Bundler] Watch error:', err.message);
1115
+ });
1116
+ }
1117
+
1118
+ if (shutdownManager) {
1119
+ shutdownManager.registerDisposer('devBundler', () => devBundler.dispose());
1120
+ }
1121
+ }
1122
+
716
1123
  // Configure Nunjucks with viewsDir priority and circular extension guard
717
1124
  const templateDirs = [viewsDir, pagesDir].filter(Boolean);
718
1125
  if (templateDirs.length === 0) {
719
1126
  templateDirs.push(path.join(rootDir, 'pages'));
720
1127
  }
721
1128
 
1129
+ const nunjucksOpts = options.nunjucks || {};
722
1130
  const nunjucksEnv = configureSafeNunjucks(templateDirs, {
723
1131
  autoescape: true,
724
1132
  express: app,
725
1133
  watch: isDev && !isTest,
726
- noCache: isDev || isTest
1134
+ noCache: nunjucksOpts.noCache ?? (isDev || isTest),
1135
+ ...nunjucksOpts,
727
1136
  });
1137
+
1138
+ if (devReloadHub && typeof devReloadHub.attachNunjucks === 'function') {
1139
+ devReloadHub.attachNunjucks(nunjucksEnv);
1140
+ }
728
1141
 
729
1142
  // Add custom Nunjucks filters
730
1143
  nunjucksEnv.addFilter('json', (obj) => {
@@ -794,16 +1207,18 @@ function createApp(options = {}) {
794
1207
  const pluginContext = { app, nunjucksEnv, options, middlewares, shutdownManager };
795
1208
  pluginManager.registerSync(plugins, pluginContext);
796
1209
 
797
- // Request logging middleware
1210
+ // Request logging & query profiling middleware
798
1211
  if (logging) {
799
- app.use((req, res, next) => {
800
- const start = Date.now();
801
- res.on('finish', () => {
802
- const duration = Date.now() - start;
803
- console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
804
- });
805
- next();
1212
+ const profilerMiddleware = createDevProfilerMiddleware({
1213
+ db: options.db ?? null,
1214
+ logger: options.logger || console,
1215
+ detectNPlusOne: devOpts.nPlusOne !== false,
1216
+ thresholdNPlusOne: devOpts.nPlusOneThreshold ?? 4,
1217
+ slowQueryMs: devOpts.slowQueryMs ?? 50,
1218
+ verbose: devOpts.verbose ?? false,
1219
+ colors: devOpts.colors !== false,
806
1220
  });
1221
+ app.use(profilerMiddleware);
807
1222
  }
808
1223
 
809
1224
  // 1. Scan and compile discovered routes (src/pages, src/api, modules/*)
@@ -827,6 +1242,86 @@ function createApp(options = {}) {
827
1242
  console.log('\nMounting routes:');
828
1243
  }
829
1244
 
1245
+ // Fast-Path Static & Dynamic Route Dispatcher ($O(1) direct static & dynamic LRU lookup)
1246
+ // Eliminates Express linear layer regex matching (matchLayer) for static, dynamic, and 404 routes
1247
+ app.use(function fastPathDispatcher(req, res, next) {
1248
+ const rawPath = req.path || (req.url ? req.url.split('?')[0] : '/');
1249
+ const pathname = rawPath.length > 1 && rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath;
1250
+ const method = req.method ? req.method.toUpperCase() : 'GET';
1251
+
1252
+ function getHandlersWithMiddlewares(routeHandlers) {
1253
+ if (prefixMiddlewares.length === 0) return routeHandlers;
1254
+ const applicable = [];
1255
+ for (let i = 0; i < prefixMiddlewares.length; i++) {
1256
+ const pm = prefixMiddlewares[i];
1257
+ if (pathname === pm.prefix || pathname.startsWith(pm.prefix + '/')) {
1258
+ applicable.push(...pm.handlers);
1259
+ }
1260
+ }
1261
+ return applicable.length > 0 ? [...applicable, ...routeHandlers] : routeHandlers;
1262
+ }
1263
+
1264
+ // 1. Static fast-path ($O(1) partitioned by method - zero string concat)
1265
+ const methodMap = fastPathStaticByMethod[method];
1266
+ let entry = methodMap ? methodMap.get(pathname) : undefined;
1267
+ if (!entry && method === 'HEAD') {
1268
+ entry = fastPathStaticByMethod.GET ? fastPathStaticByMethod.GET.get(pathname) : undefined;
1269
+ }
1270
+ if (entry) {
1271
+ if (!req.params) req.params = {};
1272
+ req.route = entry.routeInfo;
1273
+ return dispatchFastPath(getHandlersWithMiddlewares(entry.handlers), req, res, next);
1274
+ }
1275
+
1276
+ // 2. Dynamic route fast-path with LRU caching
1277
+ if (dynamicRouteList.length > 0) {
1278
+ const cacheKey = `${method} ${pathname}`;
1279
+ const cached = getDynamicCache(cacheKey);
1280
+
1281
+ if (cached === NOT_FOUND_SENTINEL) {
1282
+ return next();
1283
+ }
1284
+
1285
+ if (cached) {
1286
+ req.params = Object.assign({}, cached.params);
1287
+ req.route = cached.entry.routeInfo;
1288
+ return dispatchFastPath(getHandlersWithMiddlewares(cached.entry.handlers), req, res, next);
1289
+ }
1290
+
1291
+ if (method === 'HEAD') {
1292
+ const cachedGet = getDynamicCache(`GET ${pathname}`);
1293
+ if (cachedGet && cachedGet !== NOT_FOUND_SENTINEL) {
1294
+ req.params = Object.assign({}, cachedGet.params);
1295
+ req.route = cachedGet.entry.routeInfo;
1296
+ return dispatchFastPath(getHandlersWithMiddlewares(cachedGet.entry.handlers), req, res, next);
1297
+ }
1298
+ }
1299
+
1300
+ // Linear pattern scan over registered dynamic routes (only once per unique pathname)
1301
+ for (let i = 0; i < dynamicRouteList.length; i++) {
1302
+ const entry = dynamicRouteList[i];
1303
+ if (entry.method !== method && !(method === 'HEAD' && entry.method === 'GET')) {
1304
+ continue;
1305
+ }
1306
+
1307
+ const match = pathname.match(entry.compiled.regex);
1308
+ if (match) {
1309
+ const params = extractRouteParams(entry.compiled.paramNames, match);
1310
+ setDynamicCache(cacheKey, { entry, params });
1311
+
1312
+ req.params = Object.assign({}, params);
1313
+ req.route = entry.routeInfo;
1314
+ return dispatchFastPath(getHandlersWithMiddlewares(entry.handlers), req, res, next);
1315
+ }
1316
+ }
1317
+
1318
+ // Memoize as not found so subsequent requests for this path bypass regex scanning
1319
+ setDynamicCache(cacheKey, NOT_FOUND_SENTINEL);
1320
+ }
1321
+
1322
+ next();
1323
+ });
1324
+
830
1325
  // Mount discovered routes
831
1326
  const { routeMetadata: discoveredMeta, registerDynamicDiscoveredRoutes } = mountDiscoveredRoutes(app, routeTable, {
832
1327
  nunjucks: nunjucksEnv,
@@ -969,7 +1464,7 @@ function createApp(options = {}) {
969
1464
  : null;
970
1465
 
971
1466
  // 404 handler
972
- app.use(async (req, res) => {
1467
+ const notFoundHandler = async (req, res) => {
973
1468
  res.status(404);
974
1469
  const ctx = createErrorContext(req);
975
1470
 
@@ -1015,7 +1510,8 @@ function createApp(options = {}) {
1015
1510
  } else {
1016
1511
  res.json({ error: 'Not Found', status: 404 });
1017
1512
  }
1018
- });
1513
+ };
1514
+ app.use(notFoundHandler);
1019
1515
 
1020
1516
  let customErrorHandler = null;
1021
1517
  app.setErrorHandler = function(handler) {
@@ -1163,6 +1659,25 @@ function createApp(options = {}) {
1163
1659
  app.listen = function(...args) {
1164
1660
  const server = origListen(...args);
1165
1661
  app.server = server;
1662
+
1663
+ // HTTP Server Socket Tuning: prevent 502 race conditions behind Cloudflare / Nginx / AWS ALB
1664
+ // Node.js defaults keepAliveTimeout to 5s, whereas Cloudflare/ALB default to 60s idle keep-alive.
1665
+ // Note: Node requires headersTimeout > keepAliveTimeout.
1666
+ const serverOpts = options.server || {};
1667
+ const keepAliveTimeout = serverOpts.keepAliveTimeout != null
1668
+ ? serverOpts.keepAliveTimeout
1669
+ : 65000;
1670
+ const headersTimeout = serverOpts.headersTimeout != null
1671
+ ? serverOpts.headersTimeout
1672
+ : Math.max(keepAliveTimeout + 1000, 66000);
1673
+
1674
+ if (typeof server.keepAliveTimeout === 'number') {
1675
+ server.keepAliveTimeout = keepAliveTimeout;
1676
+ }
1677
+ if (typeof server.headersTimeout === 'number') {
1678
+ server.headersTimeout = headersTimeout;
1679
+ }
1680
+
1166
1681
  const adapter = new NodeHttpAdapter(server);
1167
1682
  shutdownManager.registerAdapter(adapter);
1168
1683
 
@@ -1170,11 +1685,62 @@ function createApp(options = {}) {
1170
1685
  shutdownManager.enableShutdownHooks();
1171
1686
  }
1172
1687
 
1688
+ // Development startup banner
1689
+ const shouldShowBanner =
1690
+ devOpts.banner !== false &&
1691
+ options.banner !== false &&
1692
+ isDev &&
1693
+ !isTest;
1694
+
1695
+ if (shouldShowBanner) {
1696
+ server.once('listening', () => {
1697
+ printDevBanner({
1698
+ app,
1699
+ server,
1700
+ routeTable,
1701
+ options,
1702
+ db: options.db ?? null,
1703
+ serviceRegistry,
1704
+ pluginManager,
1705
+ logger: options.logger || console,
1706
+ showRouteTable: devOpts.routeTable !== false && options.routeTable !== false,
1707
+ });
1708
+ });
1709
+ }
1710
+
1173
1711
  return server;
1174
1712
  };
1713
+
1714
+ app.printDevBanner = function(opts = {}) {
1715
+ printDevBanner({
1716
+ app,
1717
+ server: app.server,
1718
+ routeTable,
1719
+ options,
1720
+ db: options.db ?? null,
1721
+ serviceRegistry,
1722
+ pluginManager,
1723
+ logger: options.logger || console,
1724
+ showRouteTable: true,
1725
+ ...opts,
1726
+ });
1727
+ };
1728
+
1729
+ app.formatDevBanner = function(opts = {}) {
1730
+ return formatDevBanner({
1731
+ app,
1732
+ server: app.server,
1733
+ routeTable,
1734
+ options,
1735
+ db: options.db ?? null,
1736
+ serviceRegistry,
1737
+ pluginManager,
1738
+ ...opts,
1739
+ });
1740
+ };
1175
1741
 
1176
1742
  return { app, nunjucksEnv, pluginManager, authMiddleware, shutdownManager };
1177
1743
  }
1178
1744
 
1179
1745
  // Export for use as library
1180
- module.exports = { createApp };
1746
+ module.exports = { createApp, fastETag };