webspresso 0.0.97 → 0.0.99

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