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.
- package/.agents/ADMIN.md +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +4 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- 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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
636
|
+
wrapped = function(err, req, res, next) {
|
|
541
637
|
try {
|
|
542
638
|
const ret = fn.call(this, err, req, res, next);
|
|
543
|
-
if (ret &&
|
|
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
|
-
|
|
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
|
|
555
|
-
if (ret && typeof ret.
|
|
556
|
-
ret.catch(
|
|
671
|
+
const ret = fn(req, res, runNext);
|
|
672
|
+
if (ret && typeof ret.then === 'function') {
|
|
673
|
+
ret.catch(runNext);
|
|
557
674
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
//
|
|
578
|
-
app.use(
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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 };
|