webspresso 0.0.103 → 0.0.105

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 (165) hide show
  1. package/.agents/AGENTS.md +6 -0
  2. package/.agents/ARCHITECTURE.md +23 -0
  3. package/.agents/CACHE.md +147 -0
  4. package/.agents/CLI.md +1 -1
  5. package/.agents/EARLY_HINTS.md +120 -0
  6. package/.agents/HEALTH.md +118 -0
  7. package/.agents/LIMITS.md +109 -0
  8. package/.agents/ORM.md +155 -1
  9. package/.agents/PLUGINS.md +28 -0
  10. package/.agents/RESILIENCE.md +235 -0
  11. package/AGENTS.md +6 -0
  12. package/README.md +2 -2
  13. package/bin/commands/add-docker.js +6 -6
  14. package/bin/commands/dev.js +4 -3
  15. package/bin/commands/new.js +5 -5
  16. package/bin/commands/openapi.js +1 -1
  17. package/bin/commands/start.js +1 -1
  18. package/bin/utils/orm-map-html.js +2 -8
  19. package/bin/utils/project.js +2 -2
  20. package/core/auth/hash.js +5 -4
  21. package/core/auth/jwt.js +6 -57
  22. package/core/auth/magic-link.js +4 -5
  23. package/core/auth/manager.js +3 -5
  24. package/core/auth/oauth.js +45 -30
  25. package/core/auth/totp.js +5 -16
  26. package/core/auth/user-agent.js +1 -21
  27. package/core/cache/byte-size.js +123 -0
  28. package/core/cache/index.js +21 -0
  29. package/core/cache/lru.js +477 -0
  30. package/core/cache/tagged-lru.js +346 -0
  31. package/core/commands/registry.js +6 -20
  32. package/core/content/renderer.js +1 -13
  33. package/core/early-hints/index.js +267 -0
  34. package/core/errors/domain.js +48 -0
  35. package/core/errors/http.js +31 -0
  36. package/core/errors/index.js +10 -0
  37. package/core/errors/middleware.js +2 -8
  38. package/core/errors/normalize.js +88 -2
  39. package/core/events/discovery.js +6 -24
  40. package/core/events/dispatcher.js +2 -7
  41. package/core/forms/form-builder.js +1 -9
  42. package/core/health/index.js +41 -0
  43. package/core/health/middleware.js +213 -0
  44. package/core/health/normalize.js +140 -0
  45. package/core/health/probes.js +203 -0
  46. package/core/limits/index.js +34 -0
  47. package/core/limits/middleware.js +317 -0
  48. package/core/limits/normalize.js +222 -0
  49. package/core/orm/batch.js +70 -0
  50. package/core/orm/cache/fingerprint.js +2 -2
  51. package/core/orm/cache/memory-provider.js +27 -110
  52. package/core/orm/index.js +76 -8
  53. package/core/orm/json-fields.js +22 -8
  54. package/core/orm/model.js +10 -0
  55. package/core/orm/query-builder.js +63 -9
  56. package/core/orm/query-filters.js +393 -0
  57. package/core/orm/repository.js +318 -19
  58. package/core/orm/schema-helpers.js +8 -10
  59. package/core/orm/scopes.js +321 -1
  60. package/core/orm/transaction.js +10 -2
  61. package/core/orm/utils.js +4 -86
  62. package/core/queue/adapters/database.js +4 -8
  63. package/core/queue/manager.js +55 -41
  64. package/core/resilience/circuit-breaker.js +391 -0
  65. package/core/resilience/errors.js +63 -0
  66. package/core/resilience/index.js +53 -0
  67. package/core/resilience/registry.js +97 -0
  68. package/core/resilience/retry.js +203 -0
  69. package/core/schema-cache.js +9 -2
  70. package/core/shutdown/http-adapter.js +4 -1
  71. package/core/shutdown/shutdown-manager.js +132 -31
  72. package/core/utils/array.js +12 -0
  73. package/core/utils/base64url.js +8 -0
  74. package/core/utils/crypto.js +191 -0
  75. package/core/utils/ensure-leading-slash.js +6 -0
  76. package/core/utils/escape-html.js +7 -0
  77. package/core/utils/escape-regexp.js +7 -0
  78. package/core/utils/fetch.js +121 -0
  79. package/core/utils/fs.js +144 -0
  80. package/core/utils/get-client-ip.js +6 -0
  81. package/core/utils/hmac.js +6 -0
  82. package/core/utils/html.js +72 -0
  83. package/core/utils/ip.js +86 -0
  84. package/core/utils/json.js +98 -0
  85. package/core/utils/object.js +106 -0
  86. package/core/utils/random.js +9 -0
  87. package/core/utils/regex.js +77 -0
  88. package/core/utils/safe-compare.js +3 -0
  89. package/core/utils/sha256.js +6 -0
  90. package/core/utils/string.js +87 -0
  91. package/core/utils/strip-trailing-slash.js +6 -0
  92. package/core/utils/time.js +96 -0
  93. package/core/utils/timeout.js +348 -0
  94. package/core/utils/url.js +106 -0
  95. package/core/utils/walk.js +7 -0
  96. package/index.d.ts +627 -2
  97. package/index.js +158 -0
  98. package/package.json +1 -2
  99. package/plugins/admin-panel/auth.js +15 -7
  100. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  101. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  102. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  103. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  104. package/plugins/admin-panel/core/api-extensions.js +3 -80
  105. package/plugins/admin-panel/index.js +1 -1
  106. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  107. package/plugins/admin-panel/modules/menu.js +1 -1
  108. package/plugins/api-key/crypto.js +3 -26
  109. package/plugins/api-key/scopes.js +5 -7
  110. package/plugins/audit-log/api-handlers.js +8 -7
  111. package/plugins/audit-log/middleware.js +11 -2
  112. package/plugins/audit-log/parse.js +3 -3
  113. package/plugins/basic-auth/index.js +2 -26
  114. package/plugins/cors.js +2 -2
  115. package/plugins/csrf/index.js +7 -23
  116. package/plugins/dashboard/app.js +2 -2
  117. package/plugins/dashboard/index.js +5 -5
  118. package/plugins/data-exchange/record-selection.js +2 -5
  119. package/plugins/email/api-handlers.js +5 -18
  120. package/plugins/email/auth-bridge.js +3 -2
  121. package/plugins/file-manager/admin-component.js +3 -1
  122. package/plugins/file-manager/core.js +34 -4
  123. package/plugins/health-check.js +2 -1
  124. package/plugins/index.js +2 -0
  125. package/plugins/lazy.js +5 -6
  126. package/plugins/mcp/auth.js +1 -13
  127. package/plugins/mcp/discovery.js +18 -17
  128. package/plugins/mcp/index.js +23 -1
  129. package/plugins/mcp/transports/sse.js +10 -1
  130. package/plugins/orm-cache-admin/admin-component.js +3 -2
  131. package/plugins/polar/src/api.js +11 -3
  132. package/plugins/polar/src/webhooks.js +7 -13
  133. package/plugins/rate-limit/index.js +3 -1
  134. package/plugins/recaptcha/helpers.js +1 -7
  135. package/plugins/recaptcha/verify.js +37 -23
  136. package/plugins/redirect/index.js +57 -7
  137. package/plugins/rest-resources/index.js +1 -207
  138. package/plugins/rls/index.js +170 -0
  139. package/plugins/schema-explorer/admin-component.js +4 -2
  140. package/plugins/schema-explorer.js +40 -29
  141. package/plugins/seo-checker/index.js +5 -2
  142. package/plugins/site-analytics/tracking.js +21 -18
  143. package/plugins/sitemap.js +21 -17
  144. package/plugins/swagger.js +13 -17
  145. package/plugins/upload/index.js +4 -3
  146. package/plugins/upload/orphan-pruner.js +4 -3
  147. package/plugins/webhooks/crypto.js +6 -28
  148. package/plugins/webhooks/dispatcher.js +14 -9
  149. package/plugins/webhooks/index.js +6 -0
  150. package/plugins/webhooks/matcher.js +28 -17
  151. package/plugins/webhooks/sender.js +40 -13
  152. package/src/app-context.js +41 -5
  153. package/src/dev/banner.js +6 -2
  154. package/src/dev-reload/index.js +47 -6
  155. package/src/file-router.js +1 -3
  156. package/src/helpers.js +180 -33
  157. package/src/modules/module-discovery.js +1 -9
  158. package/src/pages/page-loader.js +53 -1
  159. package/src/plugin-manager.js +90 -17
  160. package/src/server.js +137 -26
  161. package/src/services/discovery.js +19 -40
  162. package/src/services/executor.js +86 -25
  163. package/src/services/memoize.js +3 -25
  164. package/src/services/registry.js +6 -0
  165. package/src/template/precompile.js +8 -19
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso Health & Readiness Probes Normalization
5
+ * Standardizes configuration for Docker/Kubernetes liveness (/healthz),
6
+ * readiness (/readyz), and combined diagnostics endpoints.
7
+ *
8
+ * @module core/health/normalize
9
+ */
10
+
11
+ const DEFAULT_HEALTH_CONFIG = Object.freeze({
12
+ enabled: false,
13
+ path: '/healthz',
14
+ readyPath: '/readyz',
15
+ combinedPath: '/health',
16
+ db: true,
17
+ queue: true,
18
+ eventLoopLag: true,
19
+ eventLoopThresholdMs: 300,
20
+ timeoutMs: 3000,
21
+ checks: Object.freeze({}),
22
+ headers: Object.freeze({
23
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
24
+ 'X-Content-Type-Options': 'nosniff',
25
+ }),
26
+ });
27
+
28
+ /**
29
+ * Normalizes user healthCheck configuration into a validated options object.
30
+ *
31
+ * @param {Object|boolean|string} [options]
32
+ * @returns {Object} Normalized health configuration
33
+ */
34
+ function normalizeHealthConfig(options) {
35
+ if (options === undefined || options === null || options === false) {
36
+ return {
37
+ ...DEFAULT_HEALTH_CONFIG,
38
+ enabled: false,
39
+ checks: {},
40
+ headers: { ...DEFAULT_HEALTH_CONFIG.headers },
41
+ };
42
+ }
43
+
44
+ // If top-level createApp options passed, extract options.healthCheck ?? options.server?.healthCheck
45
+ let raw = options;
46
+ if (options && typeof options === 'object') {
47
+ if ('healthCheck' in options || (options.server && 'healthCheck' in options.server)) {
48
+ raw = options.healthCheck ?? options.server?.healthCheck ?? false;
49
+ }
50
+ }
51
+
52
+ // Boolean shorthand: healthCheck: true
53
+ if (raw === true) {
54
+ return {
55
+ ...DEFAULT_HEALTH_CONFIG,
56
+ enabled: true,
57
+ checks: {},
58
+ headers: { ...DEFAULT_HEALTH_CONFIG.headers },
59
+ };
60
+ }
61
+
62
+ // Boolean false or falsy: healthCheck: false
63
+ if (!raw || raw === false) {
64
+ return {
65
+ ...DEFAULT_HEALTH_CONFIG,
66
+ enabled: false,
67
+ checks: {},
68
+ headers: { ...DEFAULT_HEALTH_CONFIG.headers },
69
+ };
70
+ }
71
+
72
+ // String shorthand: healthCheck: '/healthz' or healthCheck: '/api/health'
73
+ if (typeof raw === 'string') {
74
+ const cleanPath = raw.startsWith('/') ? raw : `/${raw}`;
75
+ return {
76
+ ...DEFAULT_HEALTH_CONFIG,
77
+ enabled: true,
78
+ path: cleanPath,
79
+ combinedPath: cleanPath,
80
+ checks: {},
81
+ headers: { ...DEFAULT_HEALTH_CONFIG.headers },
82
+ };
83
+ }
84
+
85
+ // Object configuration: healthCheck: { path, readyPath, ... }
86
+ const opts = typeof raw === 'object' ? raw : {};
87
+
88
+ const cleanPath = (p, fallback) => {
89
+ if (p === false || p === null) return false;
90
+ if (typeof p === 'string' && p.trim()) {
91
+ const trimmed = p.trim();
92
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
93
+ }
94
+ return fallback;
95
+ };
96
+
97
+ const hasExplicitConfig = Object.keys(opts).length > 0;
98
+ const enabled = opts.enabled !== undefined ? Boolean(opts.enabled) : hasExplicitConfig;
99
+ const path = cleanPath(opts.path, DEFAULT_HEALTH_CONFIG.path);
100
+ const readyPath = cleanPath(opts.readyPath, DEFAULT_HEALTH_CONFIG.readyPath);
101
+ const combinedPath = cleanPath(opts.combinedPath, DEFAULT_HEALTH_CONFIG.combinedPath);
102
+
103
+ const db = opts.db !== false;
104
+ const queue = opts.queue !== false;
105
+ const eventLoopLag = opts.eventLoopLag !== false;
106
+
107
+ const eventLoopThresholdMs = typeof opts.eventLoopThresholdMs === 'number' && opts.eventLoopThresholdMs > 0
108
+ ? opts.eventLoopThresholdMs
109
+ : DEFAULT_HEALTH_CONFIG.eventLoopThresholdMs;
110
+
111
+ const timeoutMs = typeof opts.timeoutMs === 'number' && opts.timeoutMs > 0
112
+ ? opts.timeoutMs
113
+ : DEFAULT_HEALTH_CONFIG.timeoutMs;
114
+
115
+ const checks = opts.checks && typeof opts.checks === 'object' ? { ...opts.checks } : {};
116
+
117
+ const headers = {
118
+ ...DEFAULT_HEALTH_CONFIG.headers,
119
+ ...(opts.headers && typeof opts.headers === 'object' ? opts.headers : {}),
120
+ };
121
+
122
+ return {
123
+ enabled,
124
+ path,
125
+ readyPath,
126
+ combinedPath,
127
+ db,
128
+ queue,
129
+ eventLoopLag,
130
+ eventLoopThresholdMs,
131
+ timeoutMs,
132
+ checks,
133
+ headers,
134
+ };
135
+ }
136
+
137
+ module.exports = {
138
+ DEFAULT_HEALTH_CONFIG,
139
+ normalizeHealthConfig,
140
+ };
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso System & Component Health Probes
5
+ * Gathers system metrics (uptime, memory, event loop delay),
6
+ * tests database and queue availability, and runs user-defined health probes.
7
+ *
8
+ * @module core/health/probes
9
+ */
10
+
11
+ const { monitorEventLoopDelay } = require('perf_hooks');
12
+
13
+ /**
14
+ * Formats bytes to human-readable megabytes
15
+ * @param {number} bytes
16
+ * @returns {string}
17
+ */
18
+ function formatMb(bytes) {
19
+ if (typeof bytes !== 'number' || isNaN(bytes)) return '0 MB';
20
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
21
+ }
22
+
23
+ /**
24
+ * Creates an event loop delay monitor
25
+ * @returns {{ getStats: () => Object, destroy: () => void }|null}
26
+ */
27
+ function createEventLoopMonitor() {
28
+ try {
29
+ const histogram = monitorEventLoopDelay({ resolution: 20 });
30
+ histogram.enable();
31
+
32
+ return {
33
+ getStats() {
34
+ const meanNs = histogram.mean || 0;
35
+ const maxNs = histogram.max || 0;
36
+ const p50Ns = histogram.percentile ? histogram.percentile(50) : 0;
37
+ const p99Ns = histogram.percentile ? histogram.percentile(99) : 0;
38
+
39
+ return {
40
+ meanMs: Math.round((meanNs / 1e6) * 100) / 100,
41
+ maxMs: Math.round((maxNs / 1e6) * 100) / 100,
42
+ p50Ms: Math.round((p50Ns / 1e6) * 100) / 100,
43
+ p99Ms: Math.round((p99Ns / 1e6) * 100) / 100,
44
+ };
45
+ },
46
+ destroy() {
47
+ try {
48
+ histogram.disable();
49
+ } catch {}
50
+ },
51
+ };
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Collects runtime system health metrics
59
+ * @param {Object} [monitor] - Event loop monitor instance
60
+ * @returns {Object}
61
+ */
62
+ function getSystemMetrics(monitor = null) {
63
+ const mem = process.memoryUsage();
64
+ const uptimeSeconds = Math.floor(process.uptime());
65
+
66
+ const metrics = {
67
+ uptimeSeconds,
68
+ memory: {
69
+ rss: formatMb(mem.rss),
70
+ heapUsed: formatMb(mem.heapUsed),
71
+ heapTotal: formatMb(mem.heapTotal),
72
+ external: formatMb(mem.external),
73
+ bytes: {
74
+ rss: mem.rss,
75
+ heapUsed: mem.heapUsed,
76
+ heapTotal: mem.heapTotal,
77
+ },
78
+ },
79
+ pid: process.pid,
80
+ nodeVersion: process.version,
81
+ platform: process.platform,
82
+ };
83
+
84
+ if (monitor && typeof monitor.getStats === 'function') {
85
+ metrics.eventLoopLag = monitor.getStats();
86
+ }
87
+
88
+ return metrics;
89
+ }
90
+
91
+ /**
92
+ * Safely probes database connectivity with timeout
93
+ * @param {Object} db - Webspresso database instance
94
+ * @param {number} [timeoutMs=3000] - Timeout in milliseconds
95
+ * @returns {Promise<{ status: 'up'|'down'|'skipped', latencyMs?: number, error?: string }>}
96
+ */
97
+ async function probeDatabase(db, timeoutMs = 3000) {
98
+ if (!db) {
99
+ return { status: 'skipped', message: 'No database configured' };
100
+ }
101
+
102
+ const knex = db.knex || db;
103
+ if (!knex || typeof knex.raw !== 'function') {
104
+ return { status: 'up', message: 'Custom database without raw ping' };
105
+ }
106
+
107
+ const startTime = Date.now();
108
+ let timer = null;
109
+
110
+ try {
111
+ const timeoutPromise = new Promise((_, reject) => {
112
+ timer = setTimeout(() => {
113
+ reject(new Error(`Database ping timed out after ${timeoutMs}ms`));
114
+ }, timeoutMs);
115
+ });
116
+
117
+ await Promise.race([knex.raw('SELECT 1'), timeoutPromise]);
118
+ const latencyMs = Date.now() - startTime;
119
+ return { status: 'up', latencyMs };
120
+ } catch (err) {
121
+ return { status: 'down', error: err.message };
122
+ } finally {
123
+ if (timer) clearTimeout(timer);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Probes background worker queue readiness
129
+ * @param {Object} queue - Queue instance
130
+ * @returns {Promise<{ status: 'up'|'down'|'skipped', stats?: Object, error?: string }>}
131
+ */
132
+ async function probeQueue(queue) {
133
+ if (!queue) {
134
+ return { status: 'skipped', message: 'No queue configured' };
135
+ }
136
+
137
+ try {
138
+ if (typeof queue.getStats === 'function') {
139
+ const stats = await queue.getStats();
140
+ return { status: 'up', stats };
141
+ }
142
+ if (typeof queue.isReady === 'boolean') {
143
+ return { status: queue.isReady ? 'up' : 'down' };
144
+ }
145
+ return { status: 'up' };
146
+ } catch (err) {
147
+ return { status: 'down', error: err.message };
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Runs user-registered custom health checks concurrently
153
+ * @param {Record<string, Function>} checks - Map of named probe functions
154
+ * @param {Object} context - Server context
155
+ * @param {number} [timeoutMs=3000] - Timeout per probe
156
+ * @returns {Promise<{ isHealthy: boolean, results: Record<string, Object> }>}
157
+ */
158
+ async function probeCustomChecks(checks = {}, context = {}, timeoutMs = 3000) {
159
+ const entries = Object.entries(checks);
160
+ if (entries.length === 0) {
161
+ return { isHealthy: true, results: {} };
162
+ }
163
+
164
+ const results = {};
165
+ let isHealthy = true;
166
+
167
+ const promises = entries.map(async ([name, checkFn]) => {
168
+ let timer = null;
169
+ const startTime = Date.now();
170
+ try {
171
+ const timeoutPromise = new Promise((_, reject) => {
172
+ timer = setTimeout(() => reject(new Error(`Health check "${name}" timed out after ${timeoutMs}ms`)), timeoutMs);
173
+ });
174
+
175
+ const res = await Promise.race([Promise.resolve(checkFn(context)), timeoutPromise]);
176
+ const latencyMs = Date.now() - startTime;
177
+
178
+ if (res === false || (res && typeof res === 'object' && res.status === 'down')) {
179
+ isHealthy = false;
180
+ results[name] = { status: 'down', latencyMs, ...(typeof res === 'object' ? res : {}) };
181
+ } else {
182
+ results[name] = { status: 'up', latencyMs, ...(typeof res === 'object' ? res : {}) };
183
+ }
184
+ } catch (err) {
185
+ isHealthy = false;
186
+ results[name] = { status: 'down', error: err.message };
187
+ } finally {
188
+ if (timer) clearTimeout(timer);
189
+ }
190
+ });
191
+
192
+ await Promise.all(promises);
193
+ return { isHealthy, results };
194
+ }
195
+
196
+ module.exports = {
197
+ formatMb,
198
+ createEventLoopMonitor,
199
+ getSystemMetrics,
200
+ probeDatabase,
201
+ probeQueue,
202
+ probeCustomChecks,
203
+ };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso Payload Limits & DoS Protection Module
5
+ * Configurable body size limits for JSON, URL-encoded, raw, and text payloads,
6
+ * with route-level overrides, raw body capture, and seamless 413 PayloadTooLargeError handling.
7
+ *
8
+ * @module core/limits
9
+ */
10
+
11
+ const {
12
+ DEFAULT_LIMITS,
13
+ parseByteSize,
14
+ normalizeLimits,
15
+ } = require('./normalize');
16
+
17
+ const {
18
+ matchesPath,
19
+ shouldSkipBodyParser,
20
+ shouldCaptureRawBody,
21
+ createLimitsMiddleware,
22
+ setupLimitsMiddleware,
23
+ } = require('./middleware');
24
+
25
+ module.exports = {
26
+ DEFAULT_LIMITS,
27
+ parseByteSize,
28
+ normalizeLimits,
29
+ matchesPath,
30
+ shouldSkipBodyParser,
31
+ shouldCaptureRawBody,
32
+ createLimitsMiddleware,
33
+ setupLimitsMiddleware,
34
+ };
@@ -0,0 +1,317 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso Payload Limits Middleware
5
+ * Zero-dependency body size enforcement, DoS protection, and raw body capture.
6
+ *
7
+ * @module core/limits/middleware
8
+ */
9
+
10
+ const express = require('express');
11
+ const { normalizeLimits } = require('./normalize');
12
+
13
+ /**
14
+ * Checks if a given URL pathname matches a path pattern (string, glob wildcard, or RegExp).
15
+ *
16
+ * @param {string|RegExp|Function} pattern
17
+ * @param {string} pathname
18
+ * @returns {boolean}
19
+ */
20
+ function matchesPath(pattern, pathname) {
21
+ if (!pathname || !pattern) return false;
22
+
23
+ if (typeof pattern === 'function') {
24
+ try {
25
+ return Boolean(pattern(pathname));
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ if (pattern instanceof RegExp) {
32
+ return pattern.test(pathname);
33
+ }
34
+
35
+ if (typeof pattern !== 'string') return false;
36
+
37
+ // Exact match
38
+ if (pattern === pathname) return true;
39
+
40
+ // Wildcard match (e.g. /api/webhooks/* or /api/webhooks/**)
41
+ if (pattern.endsWith('/**')) {
42
+ const base = pattern.slice(0, -3);
43
+ return pathname === base || pathname.startsWith(base.endsWith('/') ? base : `${base}/`);
44
+ }
45
+ if (pattern.endsWith('/*')) {
46
+ const base = pattern.slice(0, -2);
47
+ return pathname === base || pathname.startsWith(base.endsWith('/') ? base : `${base}/`);
48
+ }
49
+
50
+ // Parameterized express pattern: /api/users/:id/upload
51
+ if (pattern.includes(':')) {
52
+ const regexPattern = '^' + pattern.replace(/\/:[^/]+/g, '/[^/]+') + '$';
53
+ try {
54
+ return new RegExp(regexPattern).test(pathname);
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * Determines whether body parsing should be skipped for the incoming request.
65
+ *
66
+ * @param {import('express').Request} req
67
+ * @param {Array<string|RegExp|Function>} excludeList
68
+ * @returns {boolean}
69
+ */
70
+ function shouldSkipBodyParser(req, excludeList) {
71
+ if (!Array.isArray(excludeList) || excludeList.length === 0) {
72
+ return false;
73
+ }
74
+ const currentPath = req.path || '';
75
+ const originalUrl = req.originalUrl ? req.originalUrl.split('?')[0] : '';
76
+
77
+ return excludeList.some(
78
+ (pattern) => matchesPath(pattern, currentPath) || matchesPath(pattern, originalUrl)
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Determines whether raw body buffer should be captured into `req.rawBody`.
84
+ *
85
+ * @param {import('express').Request} req
86
+ * @param {boolean|Array<string|RegExp>|Function} captureSetting
87
+ * @returns {boolean}
88
+ */
89
+ function shouldCaptureRawBody(req, captureSetting) {
90
+ if (!captureSetting) return false;
91
+ if (captureSetting === true) return true;
92
+
93
+ if (typeof captureSetting === 'function') {
94
+ try {
95
+ return Boolean(captureSetting(req));
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ if (Array.isArray(captureSetting)) {
102
+ const currentPath = req.path || '';
103
+ const originalUrl = req.originalUrl ? req.originalUrl.split('?')[0] : '';
104
+ return captureSetting.some(
105
+ (pattern) => matchesPath(pattern, currentPath) || matchesPath(pattern, originalUrl)
106
+ );
107
+ }
108
+
109
+ return false;
110
+ }
111
+
112
+ /**
113
+ * Creates route-level limits middleware executing custom body parsers.
114
+ *
115
+ * @param {Object} [routeOptions]
116
+ * @returns {import('express').RequestHandler}
117
+ */
118
+ function createLimitsMiddleware(routeOptions = {}) {
119
+ const limits = normalizeLimits(routeOptions);
120
+ const parsers = [];
121
+
122
+ function makeVerify(userVerify) {
123
+ return (req, res, buf, encoding) => {
124
+ if (typeof userVerify === 'function') {
125
+ userVerify(req, res, buf, encoding);
126
+ }
127
+ if (shouldCaptureRawBody(req, limits.captureRawBody)) {
128
+ req.rawBody = buf;
129
+ }
130
+ };
131
+ }
132
+
133
+ if (limits.json.enabled) {
134
+ parsers.push(
135
+ express.json({
136
+ ...limits.json.options,
137
+ limit: limits.json.limit,
138
+ strict: limits.json.strict,
139
+ verify: makeVerify(limits.json.verify),
140
+ })
141
+ );
142
+ }
143
+
144
+ if (limits.urlencoded.enabled) {
145
+ parsers.push(
146
+ express.urlencoded({
147
+ ...limits.urlencoded.options,
148
+ limit: limits.urlencoded.limit,
149
+ extended: limits.urlencoded.extended,
150
+ parameterLimit: limits.urlencoded.parameterLimit,
151
+ verify: makeVerify(limits.urlencoded.verify),
152
+ })
153
+ );
154
+ }
155
+
156
+ if (limits.raw.enabled) {
157
+ parsers.push(
158
+ express.raw({
159
+ ...limits.raw.options,
160
+ limit: limits.raw.limit,
161
+ type: limits.raw.type,
162
+ verify: makeVerify(limits.raw.verify),
163
+ })
164
+ );
165
+ }
166
+
167
+ if (limits.text.enabled) {
168
+ parsers.push(
169
+ express.text({
170
+ ...limits.text.options,
171
+ limit: limits.text.limit,
172
+ type: limits.text.type,
173
+ verify: makeVerify(limits.text.verify),
174
+ })
175
+ );
176
+ }
177
+
178
+ return function routeLimitsMiddleware(req, res, next) {
179
+ if (shouldSkipBodyParser(req, limits.exclude)) {
180
+ return next();
181
+ }
182
+
183
+ let idx = 0;
184
+ function runNextParser(err) {
185
+ if (err) return next(err);
186
+ if (idx >= parsers.length) return next();
187
+ const parser = parsers[idx++];
188
+ parser(req, res, runNextParser);
189
+ }
190
+
191
+ runNextParser();
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Mounts payload limits and body parsing middleware onto an Express application.
197
+ *
198
+ * @param {import('express').Application} app
199
+ * @param {Object} [options]
200
+ * @returns {Object} Normalized limits configuration
201
+ */
202
+ function setupLimitsMiddleware(app, options = {}) {
203
+ const limits = normalizeLimits(options);
204
+
205
+ // Expose limits on app instance for introspection
206
+ app.limits = limits;
207
+ if (typeof app.set === 'function') {
208
+ app.set('webspresso.limits', limits);
209
+ }
210
+
211
+ const allExcludes = [...limits.exclude];
212
+
213
+ // 1. Mount route-specific overrides (mounted first so they take precedence)
214
+ if (limits.routes && typeof limits.routes === 'object') {
215
+ for (const [routePattern, routeConfig] of Object.entries(limits.routes)) {
216
+ if (routeConfig === false) {
217
+ // Entirely exclude this route from global body parsing
218
+ allExcludes.push(routePattern);
219
+ } else if (routeConfig && typeof routeConfig === 'object') {
220
+ // Mount route-specific body parsers on the path
221
+ const routeMw = createLimitsMiddleware(routeConfig);
222
+ app.use(routePattern, routeMw);
223
+ }
224
+ }
225
+ }
226
+
227
+ // 2. Setup Global Body Parsers with exclusion checking and raw body capture
228
+ function globalVerify(userVerify) {
229
+ return (req, res, buf, encoding) => {
230
+ if (typeof userVerify === 'function') {
231
+ userVerify(req, res, buf, encoding);
232
+ }
233
+ if (shouldCaptureRawBody(req, limits.captureRawBody)) {
234
+ req.rawBody = buf;
235
+ }
236
+ };
237
+ }
238
+
239
+ // Global JSON Parser
240
+ if (limits.json.enabled) {
241
+ const jsonParserFn = express.json({
242
+ ...limits.json.options,
243
+ limit: limits.json.limit,
244
+ strict: limits.json.strict,
245
+ verify: globalVerify(limits.json.verify),
246
+ });
247
+
248
+ app.use(function jsonParser(req, res, next) {
249
+ if (req._body || shouldSkipBodyParser(req, allExcludes)) {
250
+ return next();
251
+ }
252
+ jsonParserFn(req, res, next);
253
+ });
254
+ }
255
+
256
+ // Global URL-Encoded Parser
257
+ if (limits.urlencoded.enabled) {
258
+ const urlencodedParserFn = express.urlencoded({
259
+ ...limits.urlencoded.options,
260
+ limit: limits.urlencoded.limit,
261
+ extended: limits.urlencoded.extended,
262
+ parameterLimit: limits.urlencoded.parameterLimit,
263
+ verify: globalVerify(limits.urlencoded.verify),
264
+ });
265
+
266
+ app.use(function urlencodedParser(req, res, next) {
267
+ if (req._body || shouldSkipBodyParser(req, allExcludes)) {
268
+ return next();
269
+ }
270
+ urlencodedParserFn(req, res, next);
271
+ });
272
+ }
273
+
274
+ // Global Raw Body Parser
275
+ if (limits.raw.enabled) {
276
+ const rawParser = express.raw({
277
+ ...limits.raw.options,
278
+ limit: limits.raw.limit,
279
+ type: limits.raw.type,
280
+ verify: globalVerify(limits.raw.verify),
281
+ });
282
+
283
+ app.use((req, res, next) => {
284
+ if (req._body || shouldSkipBodyParser(req, allExcludes)) {
285
+ return next();
286
+ }
287
+ rawParser(req, res, next);
288
+ });
289
+ }
290
+
291
+ // Global Text Parser
292
+ if (limits.text.enabled) {
293
+ const textParser = express.text({
294
+ ...limits.text.options,
295
+ limit: limits.text.limit,
296
+ type: limits.text.type,
297
+ verify: globalVerify(limits.text.verify),
298
+ });
299
+
300
+ app.use((req, res, next) => {
301
+ if (req._body || shouldSkipBodyParser(req, allExcludes)) {
302
+ return next();
303
+ }
304
+ textParser(req, res, next);
305
+ });
306
+ }
307
+
308
+ return limits;
309
+ }
310
+
311
+ module.exports = {
312
+ matchesPath,
313
+ shouldSkipBodyParser,
314
+ shouldCaptureRawBody,
315
+ createLimitsMiddleware,
316
+ setupLimitsMiddleware,
317
+ };