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
@@ -180,6 +180,51 @@ class RouteGenerationError extends RouterError {
180
180
  }
181
181
  }
182
182
 
183
+ /**
184
+ * Circuit Breaker Open Error
185
+ * Thrown when an invocation is rejected because the circuit is in the OPEN state.
186
+ * Maps to HTTP 503 Service Unavailable.
187
+ */
188
+ class CircuitBreakerOpenError extends HttpError {
189
+ /**
190
+ * @param {string} [breakerName='default']
191
+ * @param {number} [resetTimeoutMs=0]
192
+ * @param {Object} [options]
193
+ */
194
+ constructor(breakerName = 'default', resetTimeoutMs = 0, options = {}) {
195
+ super(503, `Circuit breaker '${breakerName}' is OPEN and failing fast`, {
196
+ code: 'CIRCUIT_BREAKER_OPEN',
197
+ expose: true,
198
+ ...options,
199
+ });
200
+ this.breakerName = breakerName;
201
+ this.resetTimeoutMs = resetTimeoutMs;
202
+ this.state = 'OPEN';
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Circuit Breaker Timeout Error
208
+ * Thrown when execution inside a circuit breaker exceeds timeout.
209
+ * Maps to HTTP 504 Gateway Timeout.
210
+ */
211
+ class CircuitBreakerTimeoutError extends HttpError {
212
+ /**
213
+ * @param {string} [breakerName='default']
214
+ * @param {number} [timeoutMs=0]
215
+ * @param {Object} [options]
216
+ */
217
+ constructor(breakerName = 'default', timeoutMs = 0, options = {}) {
218
+ super(504, `Execution in circuit breaker '${breakerName}' timed out after ${timeoutMs}ms`, {
219
+ code: 'CIRCUIT_BREAKER_TIMEOUT',
220
+ expose: true,
221
+ ...options,
222
+ });
223
+ this.breakerName = breakerName;
224
+ this.timeoutMs = timeoutMs;
225
+ }
226
+ }
227
+
183
228
  module.exports = {
184
229
  ValidationError,
185
230
  ConfigurationError,
@@ -190,4 +235,7 @@ module.exports = {
190
235
  RouterError,
191
236
  RouteNotFoundError,
192
237
  RouteGenerationError,
238
+ CircuitBreakerOpenError,
239
+ CircuitBreakerTimeoutError,
193
240
  };
241
+
@@ -109,6 +109,15 @@ class UnprocessableEntityError extends HttpError {
109
109
  }
110
110
  }
111
111
 
112
+ /**
113
+ * 408 Request Timeout
114
+ */
115
+ class RequestTimeoutError extends HttpError {
116
+ constructor(message = 'Request Timeout', options = {}) {
117
+ super(408, message, { code: 'REQUEST_TIMEOUT', ...options });
118
+ }
119
+ }
120
+
112
121
  /**
113
122
  * 429 Too Many Requests
114
123
  */
@@ -118,6 +127,24 @@ class TooManyRequestsError extends HttpError {
118
127
  }
119
128
  }
120
129
 
130
+ /**
131
+ * 503 Service Unavailable
132
+ */
133
+ class ServiceUnavailableError extends HttpError {
134
+ constructor(message = 'Service Unavailable', options = {}) {
135
+ super(503, message, { code: 'SERVICE_UNAVAILABLE', ...options });
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 504 Gateway Timeout
141
+ */
142
+ class GatewayTimeoutError extends HttpError {
143
+ constructor(message = 'Gateway Timeout', options = {}) {
144
+ super(504, message, { code: 'GATEWAY_TIMEOUT', ...options });
145
+ }
146
+ }
147
+
121
148
  module.exports = {
122
149
  HttpError,
123
150
  BadRequestError,
@@ -129,5 +156,9 @@ module.exports = {
129
156
  PayloadTooLargeError,
130
157
  UnsupportedMediaTypeError,
131
158
  UnprocessableEntityError,
159
+ RequestTimeoutError,
132
160
  TooManyRequestsError,
161
+ ServiceUnavailableError,
162
+ GatewayTimeoutError,
133
163
  };
164
+
@@ -15,7 +15,10 @@ const {
15
15
  PayloadTooLargeError,
16
16
  UnsupportedMediaTypeError,
17
17
  UnprocessableEntityError,
18
+ RequestTimeoutError,
18
19
  TooManyRequestsError,
20
+ ServiceUnavailableError,
21
+ GatewayTimeoutError,
19
22
  } = require('./http');
20
23
  const {
21
24
  ValidationError,
@@ -27,6 +30,8 @@ const {
27
30
  RouterError,
28
31
  RouteNotFoundError,
29
32
  RouteGenerationError,
33
+ CircuitBreakerOpenError,
34
+ CircuitBreakerTimeoutError,
30
35
  } = require('./domain');
31
36
  const {
32
37
  getStatusTitle,
@@ -54,7 +59,10 @@ module.exports = {
54
59
  PayloadTooLargeError,
55
60
  UnsupportedMediaTypeError,
56
61
  UnprocessableEntityError,
62
+ RequestTimeoutError,
57
63
  TooManyRequestsError,
64
+ ServiceUnavailableError,
65
+ GatewayTimeoutError,
58
66
 
59
67
  // Domain Exceptions
60
68
  ValidationError,
@@ -66,6 +74,8 @@ module.exports = {
66
74
  RouterError,
67
75
  RouteNotFoundError,
68
76
  RouteGenerationError,
77
+ CircuitBreakerOpenError,
78
+ CircuitBreakerTimeoutError,
69
79
 
70
80
  // Utilities & Middleware
71
81
  getStatusTitle,
@@ -5,6 +5,7 @@
5
5
 
6
6
  const { normalizeError, toErrorResponseObject } = require('./normalize');
7
7
  const { RequestAbortedError } = require('./domain');
8
+ const { escapeHtml } = require('../utils/html');
8
9
 
9
10
  /**
10
11
  * Check if the request prefers a JSON error response
@@ -60,14 +61,7 @@ function renderDefaultErrorHtml(err, isDev) {
60
61
  </html>`;
61
62
  }
62
63
 
63
- function escapeHtml(str) {
64
- return String(str || '')
65
- .replace(/&/g, '&amp;')
66
- .replace(/</g, '&lt;')
67
- .replace(/>/g, '&gt;')
68
- .replace(/"/g, '&quot;')
69
- .replace(/'/g, '&#39;');
70
- }
64
+
71
65
 
72
66
  /**
73
67
  * Create Webspresso Central Error Handler Middleware
@@ -5,7 +5,11 @@
5
5
  */
6
6
 
7
7
  const { WebspressoError } = require('./base');
8
- const { HttpError } = require('./http');
8
+ const {
9
+ HttpError,
10
+ BadRequestError,
11
+ PayloadTooLargeError,
12
+ } = require('./http');
9
13
 
10
14
  const HTTP_TITLES = {
11
15
  400: 'Bad Request',
@@ -62,6 +66,60 @@ function normalizeError(err, isDev = false) {
62
66
  const isClientError = status < 500;
63
67
  const expose = typeof err.expose === 'boolean' ? err.expose : isClientError;
64
68
 
69
+ // Body parser / payload limit error mapping (HTTP 413)
70
+ if (status === 413 || err.type === 'entity.too.large' || err.type === 'parameters.too.many') {
71
+ const code = err.code || (err.type === 'parameters.too.many' ? 'PARAMETERS_TOO_MANY' : 'PAYLOAD_TOO_LARGE');
72
+ const details = err.details || (
73
+ (err.limit !== undefined || err.length !== undefined || err.expected !== undefined)
74
+ ? { limit: err.limit, length: err.length, expected: err.expected }
75
+ : (err.type ? { type: err.type } : undefined)
76
+ );
77
+ const payloadErr = new PayloadTooLargeError(
78
+ (expose || isDev) ? (err.message || getStatusTitle(413)) : getStatusTitle(413),
79
+ {
80
+ code,
81
+ details,
82
+ cause: err,
83
+ headers: err.headers,
84
+ expose,
85
+ }
86
+ );
87
+
88
+ if (err.stack) payloadErr.stack = err.stack;
89
+ if (err.route) payloadErr.route = err.route;
90
+ if (err.method) payloadErr.method = err.method;
91
+ if (err.source) payloadErr.source = err.source;
92
+ if (err.module) payloadErr.module = err.module;
93
+ if (err.phase) payloadErr.phase = err.phase;
94
+ if (err.requestId) payloadErr.requestId = err.requestId;
95
+
96
+ return payloadErr;
97
+ }
98
+
99
+ // Body parser JSON syntax error (HTTP 400)
100
+ if (err.type === 'entity.parse.failed') {
101
+ const badReqErr = new BadRequestError(
102
+ (expose || isDev) ? (err.message || 'Invalid JSON payload') : 'Invalid JSON payload',
103
+ {
104
+ code: err.code || 'INVALID_JSON_PAYLOAD',
105
+ details: err.details || { type: err.type },
106
+ cause: err,
107
+ headers: err.headers,
108
+ expose: true,
109
+ }
110
+ );
111
+
112
+ if (err.stack) badReqErr.stack = err.stack;
113
+ if (err.route) badReqErr.route = err.route;
114
+ if (err.method) badReqErr.method = err.method;
115
+ if (err.source) badReqErr.source = err.source;
116
+ if (err.module) badReqErr.module = err.module;
117
+ if (err.phase) badReqErr.phase = err.phase;
118
+ if (err.requestId) badReqErr.requestId = err.requestId;
119
+
120
+ return badReqErr;
121
+ }
122
+
65
123
  const httpErr = new HttpError(
66
124
  status,
67
125
  (expose || isDev) ? (err.message || getStatusTitle(status)) : getStatusTitle(status),
@@ -105,13 +163,41 @@ function normalizeError(err, isDev = false) {
105
163
  ? err.status
106
164
  : (typeof err.statusCode === 'number' ? err.statusCode : 500);
107
165
 
166
+ const isClientError = status < 500;
167
+ const expose = typeof err.expose === 'boolean' ? err.expose : isClientError;
168
+
169
+ if (status === 413 || err.type === 'entity.too.large' || err.type === 'parameters.too.many') {
170
+ const code = err.code || (err.type === 'parameters.too.many' ? 'PARAMETERS_TOO_MANY' : 'PAYLOAD_TOO_LARGE');
171
+ const details = err.details || (
172
+ (err.limit !== undefined || err.length !== undefined || err.expected !== undefined)
173
+ ? { limit: err.limit, length: err.length, expected: err.expected }
174
+ : (err.type ? { type: err.type } : undefined)
175
+ );
176
+ const payloadErr = new PayloadTooLargeError(
177
+ (expose || isDev) ? (err.message || getStatusTitle(413)) : getStatusTitle(413),
178
+ {
179
+ code,
180
+ details,
181
+ headers: err.headers,
182
+ expose,
183
+ }
184
+ );
185
+ if (err.route) payloadErr.route = err.route;
186
+ if (err.method) payloadErr.method = err.method;
187
+ if (err.source) payloadErr.source = err.source;
188
+ if (err.module) payloadErr.module = err.module;
189
+ if (err.phase) payloadErr.phase = err.phase;
190
+ if (err.requestId) payloadErr.requestId = err.requestId;
191
+ return payloadErr;
192
+ }
193
+
108
194
  const msg = typeof err.message === 'string' ? err.message : getStatusTitle(status);
109
195
 
110
196
  const httpErr = new HttpError(status, (status < 500 || isDev) ? msg : getStatusTitle(status), {
111
197
  code: err.code,
112
198
  details: err.details,
113
199
  headers: err.headers,
114
- expose: typeof err.expose === 'boolean' ? err.expose : status < 500,
200
+ expose,
115
201
  });
116
202
 
117
203
  if (err.route) httpErr.route = err.route;
@@ -6,6 +6,7 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
+ const { walkDirectory } = require('../utils/fs');
9
10
 
10
11
  /**
11
12
  * Define a typed event listener
@@ -44,29 +45,6 @@ function filePathToEventName(relPath) {
44
45
  return parts.join('.');
45
46
  }
46
47
 
47
- /**
48
- * Recursively find all .js / .cjs files in a directory
49
- * @param {string} dir
50
- * @returns {string[]}
51
- */
52
- function walkDirectory(dir) {
53
- const files = [];
54
- if (!fs.existsSync(dir)) return files;
55
-
56
- const entries = fs.readdirSync(dir, { withFileTypes: true });
57
- for (const entry of entries) {
58
- const fullPath = path.join(dir, entry.name);
59
- if (entry.isDirectory()) {
60
- files.push(...walkDirectory(fullPath));
61
- } else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.cjs'))) {
62
- if (!entry.name.startsWith('_') && !entry.name.endsWith('.test.js')) {
63
- files.push(fullPath);
64
- }
65
- }
66
- }
67
- return files;
68
- }
69
-
70
48
  /**
71
49
  * Load and register listeners from a directory into an EventDispatcher
72
50
  * @param {string} dirPath - Absolute or relative directory path
@@ -84,7 +62,11 @@ function loadListeners(dirPath, dispatcher, options = {}) {
84
62
  return [];
85
63
  }
86
64
 
87
- const filePaths = walkDirectory(resolvedDir);
65
+ const filePaths = walkDirectory(resolvedDir, {
66
+ extensions: ['.js', '.cjs'],
67
+ ignorePrefixes: ['_', '.'],
68
+ ignoreTests: true,
69
+ });
88
70
  const loaded = [];
89
71
 
90
72
  for (const filePath of filePaths) {
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  const EventEmitter = require('events');
9
+ const { globToRegExp } = require('../utils/regex');
9
10
 
10
11
  /**
11
12
  * Convert a glob/wildcard pattern (e.g. 'order.*', 'order.**', '*') into a RegExp
@@ -21,13 +22,7 @@ function patternToRegExp(pattern) {
21
22
  const cached = patternRegexCache.get(pattern);
22
23
  if (cached !== undefined) return cached;
23
24
 
24
- const escaped = pattern
25
- .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
26
- .replace(/\*\*/g, '___DOUBLE_STAR___')
27
- .replace(/\*/g, '[^.]+')
28
- .replace(/___DOUBLE_STAR___/g, '.*');
29
-
30
- const reg = new RegExp(`^${escaped}$`);
25
+ const reg = globToRegExp(pattern, { separator: '.' });
31
26
  if (patternRegexCache.size < 500) {
32
27
  patternRegexCache.set(pattern, reg);
33
28
  }
@@ -4,15 +4,7 @@
4
4
  * @module core/forms/form-builder
5
5
  */
6
6
 
7
- function escapeHtml(str) {
8
- if (str === null || str === undefined) return '';
9
- return String(str)
10
- .replace(/&/g, '&amp;')
11
- .replace(/</g, '&lt;')
12
- .replace(/>/g, '&gt;')
13
- .replace(/"/g, '&quot;')
14
- .replace(/'/g, '&#039;');
15
- }
7
+ const { escapeHtml } = require('../utils/html');
16
8
 
17
9
  /**
18
10
  * Render a single form field with label, input, and validation error message
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso Health & Readiness Probes Module
5
+ * Kubernetes, Docker & Cloud-ready liveness (/healthz), readiness (/readyz),
6
+ * and diagnostics endpoints with database pings, queue stats, and graceful draining integration.
7
+ *
8
+ * @module core/health
9
+ */
10
+
11
+ const {
12
+ DEFAULT_HEALTH_CONFIG,
13
+ normalizeHealthConfig,
14
+ } = require('./normalize');
15
+
16
+ const {
17
+ formatMb,
18
+ createEventLoopMonitor,
19
+ getSystemMetrics,
20
+ probeDatabase,
21
+ probeQueue,
22
+ probeCustomChecks,
23
+ } = require('./probes');
24
+
25
+ const {
26
+ createHealthManager,
27
+ setupHealthMiddleware,
28
+ } = require('./middleware');
29
+
30
+ module.exports = {
31
+ DEFAULT_HEALTH_CONFIG,
32
+ normalizeHealthConfig,
33
+ formatMb,
34
+ createEventLoopMonitor,
35
+ getSystemMetrics,
36
+ probeDatabase,
37
+ probeQueue,
38
+ probeCustomChecks,
39
+ createHealthManager,
40
+ setupHealthMiddleware,
41
+ };
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso Health Check Middleware & Coordinator
5
+ * Exposes /healthz (liveness) and /readyz (readiness) endpoints conforming to cloud & Kubernetes standards.
6
+ *
7
+ * @module core/health/middleware
8
+ */
9
+
10
+ const { normalizeHealthConfig } = require('./normalize');
11
+ const {
12
+ createEventLoopMonitor,
13
+ getSystemMetrics,
14
+ probeDatabase,
15
+ probeQueue,
16
+ probeCustomChecks,
17
+ } = require('./probes');
18
+
19
+ /**
20
+ * Creates a health manager instance coordinating probes and runtime metrics.
21
+ *
22
+ * @param {Object} config - Normalized health configuration
23
+ * @param {Object} [context={}] - Server context (db, queue, shutdownManager)
24
+ * @returns {Object} HealthManager
25
+ */
26
+ function createHealthManager(config, context = {}) {
27
+ const eventLoopMonitor = config.eventLoopLag ? createEventLoopMonitor() : null;
28
+
29
+ async function getLiveness() {
30
+ const metrics = getSystemMetrics(eventLoopMonitor);
31
+ let status = 'ok';
32
+
33
+ if (config.eventLoopLag && metrics.eventLoopLag && config.eventLoopThresholdMs) {
34
+ if (metrics.eventLoopLag.meanMs > config.eventLoopThresholdMs) {
35
+ status = 'degraded';
36
+ }
37
+ }
38
+
39
+ return {
40
+ status,
41
+ timestamp: new Date().toISOString(),
42
+ ...metrics,
43
+ };
44
+ }
45
+
46
+ async function getReadiness() {
47
+ const shutdownManager = context.shutdownManager || context.app?.shutdownManager;
48
+
49
+ // 1. Check if server is currently draining connections during shutdown
50
+ if (shutdownManager && (shutdownManager.isShuttingDown || shutdownManager.isClosed)) {
51
+ return {
52
+ ready: false,
53
+ status: 'draining',
54
+ message: 'Server is currently shutting down and draining active connections',
55
+ timestamp: new Date().toISOString(),
56
+ };
57
+ }
58
+
59
+ let isReady = true;
60
+ const checks = {};
61
+
62
+ // 2. Check Database connectivity
63
+ if (config.db && context.db) {
64
+ const dbResult = await probeDatabase(context.db, config.timeoutMs);
65
+ checks.database = dbResult;
66
+ if (dbResult.status === 'down') {
67
+ isReady = false;
68
+ }
69
+ }
70
+
71
+ // 3. Check Queue readiness
72
+ if (config.queue && context.queue) {
73
+ const queueResult = await probeQueue(context.queue);
74
+ checks.queue = queueResult;
75
+ if (queueResult.status === 'down') {
76
+ isReady = false;
77
+ }
78
+ }
79
+
80
+ // 4. Run user-defined custom checks
81
+ if (config.checks && Object.keys(config.checks).length > 0) {
82
+ const customResults = await probeCustomChecks(config.checks, context, config.timeoutMs);
83
+ checks.custom = customResults.results;
84
+ if (!customResults.isHealthy) {
85
+ isReady = false;
86
+ }
87
+ }
88
+
89
+ return {
90
+ ready: isReady,
91
+ status: isReady ? 'ready' : 'unhealthy',
92
+ timestamp: new Date().toISOString(),
93
+ checks,
94
+ };
95
+ }
96
+
97
+ async function getStatus() {
98
+ const [liveness, readiness] = await Promise.all([getLiveness(), getReadiness()]);
99
+ return {
100
+ status: readiness.ready ? liveness.status : 'unhealthy',
101
+ liveness,
102
+ readiness,
103
+ };
104
+ }
105
+
106
+ function destroy() {
107
+ if (eventLoopMonitor) {
108
+ eventLoopMonitor.destroy();
109
+ }
110
+ }
111
+
112
+ return {
113
+ config,
114
+ getLiveness,
115
+ getReadiness,
116
+ getStatus,
117
+ destroy,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Mounts standard health check endpoints onto the Express app
123
+ *
124
+ * @param {import('express').Application} app
125
+ * @param {Object} [options] - Health check options or createApp options
126
+ * @param {Object} [context={}] - Server context (db, queue, shutdownManager)
127
+ * @returns {Object|null} Active HealthManager or null if disabled
128
+ */
129
+ function setupHealthMiddleware(app, options = {}, context = {}) {
130
+ const config = normalizeHealthConfig(options);
131
+ if (!config.enabled) {
132
+ return null;
133
+ }
134
+
135
+ const serverContext = {
136
+ ...context,
137
+ app,
138
+ db: context.db || app.db || (app.get ? app.get('db') : null),
139
+ queue: context.queue || app.queue || (app.get ? app.get('queue') : null),
140
+ shutdownManager: context.shutdownManager || app.shutdownManager,
141
+ };
142
+
143
+ const healthManager = createHealthManager(config, serverContext);
144
+
145
+ // Helper to attach configured headers (Cache-Control: no-store, etc.)
146
+ function applyHealthHeaders(res) {
147
+ if (config.headers && typeof config.headers === 'object') {
148
+ for (const [key, val] of Object.entries(config.headers)) {
149
+ res.setHeader(key, val);
150
+ }
151
+ }
152
+ }
153
+
154
+ // 1. Mount Liveness Endpoint (/healthz)
155
+ if (config.path) {
156
+ app.get(config.path, async (req, res) => {
157
+ try {
158
+ applyHealthHeaders(res);
159
+ const liveness = await healthManager.getLiveness();
160
+ const statusCode = liveness.status === 'ok' ? 200 : 200; // Liveness passes if process responds
161
+ return res.status(statusCode).json(liveness);
162
+ } catch (err) {
163
+ return res.status(500).json({ status: 'error', error: err.message });
164
+ }
165
+ });
166
+ }
167
+
168
+ // 2. Mount Readiness Endpoint (/readyz)
169
+ if (config.readyPath && config.readyPath !== config.path) {
170
+ app.get(config.readyPath, async (req, res) => {
171
+ try {
172
+ applyHealthHeaders(res);
173
+ const readiness = await healthManager.getReadiness();
174
+ const statusCode = readiness.ready ? 200 : 503;
175
+ return res.status(statusCode).json(readiness);
176
+ } catch (err) {
177
+ return res.status(503).json({ ready: false, status: 'error', error: err.message });
178
+ }
179
+ });
180
+ }
181
+
182
+ // 3. Mount Combined Diagnostic Endpoint (/health)
183
+ if (config.combinedPath && config.combinedPath !== config.path && config.combinedPath !== config.readyPath) {
184
+ app.get(config.combinedPath, async (req, res) => {
185
+ try {
186
+ applyHealthHeaders(res);
187
+ const status = await healthManager.getStatus();
188
+ const statusCode = status.readiness.ready ? 200 : 503;
189
+ return res.status(statusCode).json(status);
190
+ } catch (err) {
191
+ return res.status(503).json({ status: 'error', error: err.message });
192
+ }
193
+ });
194
+ }
195
+
196
+ // Decorate app for programmatic inspection
197
+ app.healthManager = healthManager;
198
+ app.getHealthStatus = () => healthManager.getStatus();
199
+
200
+ // Attach cleanup to shutdownManager
201
+ if (serverContext.shutdownManager && typeof serverContext.shutdownManager.onShutdown === 'function') {
202
+ serverContext.shutdownManager.onShutdown(() => {
203
+ healthManager.destroy();
204
+ });
205
+ }
206
+
207
+ return healthManager;
208
+ }
209
+
210
+ module.exports = {
211
+ createHealthManager,
212
+ setupHealthMiddleware,
213
+ };