webspresso 0.0.104 → 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.
- package/.agents/AGENTS.md +6 -0
- package/.agents/ARCHITECTURE.md +23 -0
- package/.agents/CACHE.md +147 -0
- package/.agents/CLI.md +1 -1
- package/.agents/EARLY_HINTS.md +120 -0
- package/.agents/HEALTH.md +118 -0
- package/.agents/LIMITS.md +109 -0
- package/.agents/ORM.md +155 -1
- package/.agents/PLUGINS.md +28 -0
- package/.agents/RESILIENCE.md +235 -0
- package/AGENTS.md +6 -0
- package/README.md +2 -2
- package/bin/commands/add-docker.js +6 -6
- package/bin/commands/dev.js +1 -1
- package/bin/commands/new.js +5 -5
- package/bin/commands/openapi.js +1 -1
- package/bin/commands/start.js +1 -1
- package/bin/utils/project.js +2 -2
- package/core/auth/hash.js +5 -4
- package/core/auth/jwt.js +5 -54
- package/core/auth/magic-link.js +4 -5
- package/core/auth/manager.js +3 -5
- package/core/auth/oauth.js +8 -17
- package/core/auth/totp.js +2 -1
- package/core/cache/byte-size.js +123 -0
- package/core/cache/index.js +21 -0
- package/core/cache/lru.js +477 -0
- package/core/cache/tagged-lru.js +346 -0
- package/core/commands/registry.js +6 -20
- package/core/early-hints/index.js +267 -0
- package/core/errors/domain.js +48 -0
- package/core/errors/http.js +31 -0
- package/core/errors/index.js +10 -0
- package/core/errors/normalize.js +88 -2
- package/core/events/discovery.js +6 -24
- package/core/events/dispatcher.js +2 -7
- package/core/health/index.js +41 -0
- package/core/health/middleware.js +213 -0
- package/core/health/normalize.js +140 -0
- package/core/health/probes.js +203 -0
- package/core/limits/index.js +34 -0
- package/core/limits/middleware.js +317 -0
- package/core/limits/normalize.js +222 -0
- package/core/orm/batch.js +70 -0
- package/core/orm/cache/fingerprint.js +2 -2
- package/core/orm/cache/memory-provider.js +27 -110
- package/core/orm/index.js +71 -8
- package/core/orm/json-fields.js +22 -8
- package/core/orm/model.js +10 -0
- package/core/orm/query-builder.js +63 -9
- package/core/orm/repository.js +318 -19
- package/core/orm/schema-helpers.js +8 -10
- package/core/orm/scopes.js +321 -1
- package/core/orm/transaction.js +10 -2
- package/core/orm/utils.js +4 -86
- package/core/queue/adapters/database.js +4 -8
- package/core/queue/manager.js +55 -41
- package/core/resilience/circuit-breaker.js +391 -0
- package/core/resilience/errors.js +63 -0
- package/core/resilience/index.js +53 -0
- package/core/resilience/registry.js +97 -0
- package/core/resilience/retry.js +203 -0
- package/core/schema-cache.js +9 -2
- package/core/shutdown/http-adapter.js +4 -1
- package/core/shutdown/shutdown-manager.js +120 -19
- package/core/utils/array.js +12 -0
- package/core/utils/base64url.js +8 -0
- package/core/utils/crypto.js +118 -0
- package/core/utils/ensure-leading-slash.js +6 -0
- package/core/utils/escape-regexp.js +7 -0
- package/core/utils/fetch.js +6 -5
- package/core/utils/fs.js +144 -0
- package/core/utils/hmac.js +6 -0
- package/core/utils/json.js +98 -0
- package/core/utils/object.js +106 -0
- package/core/utils/random.js +9 -0
- package/core/utils/regex.js +77 -0
- package/core/utils/sha256.js +6 -0
- package/core/utils/string.js +87 -0
- package/core/utils/strip-trailing-slash.js +6 -0
- package/core/utils/time.js +96 -0
- package/core/utils/timeout.js +348 -0
- package/core/utils/url.js +106 -0
- package/core/utils/walk.js +7 -0
- package/index.d.ts +616 -1
- package/index.js +148 -0
- package/package.json +1 -2
- package/plugins/admin-panel/core/api-extensions.js +2 -5
- package/plugins/admin-panel/index.js +1 -1
- package/plugins/admin-panel/modules/menu.js +1 -1
- package/plugins/api-key/crypto.js +3 -3
- package/plugins/api-key/scopes.js +5 -7
- package/plugins/audit-log/api-handlers.js +6 -15
- package/plugins/audit-log/parse.js +3 -3
- package/plugins/csrf/index.js +5 -9
- package/plugins/dashboard/app.js +2 -2
- package/plugins/dashboard/index.js +5 -5
- package/plugins/data-exchange/record-selection.js +2 -5
- package/plugins/email/api-handlers.js +5 -18
- package/plugins/email/auth-bridge.js +3 -2
- package/plugins/health-check.js +2 -1
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +5 -6
- package/plugins/mcp/discovery.js +18 -17
- package/plugins/polar/src/api.js +2 -1
- package/plugins/polar/src/webhooks.js +5 -6
- package/plugins/rate-limit/index.js +3 -1
- package/plugins/redirect/index.js +9 -7
- package/plugins/rls/index.js +170 -0
- package/plugins/site-analytics/tracking.js +2 -2
- package/plugins/sitemap.js +3 -2
- package/plugins/swagger.js +4 -3
- package/plugins/upload/index.js +4 -3
- package/plugins/upload/orphan-pruner.js +4 -3
- package/plugins/webhooks/crypto.js +6 -11
- package/plugins/webhooks/dispatcher.js +8 -8
- package/plugins/webhooks/index.js +6 -0
- package/plugins/webhooks/matcher.js +7 -11
- package/plugins/webhooks/sender.js +8 -11
- package/src/app-context.js +41 -5
- package/src/dev/banner.js +6 -2
- package/src/file-router.js +1 -3
- package/src/helpers.js +159 -24
- package/src/modules/module-discovery.js +1 -9
- package/src/pages/page-loader.js +53 -1
- package/src/plugin-manager.js +90 -17
- package/src/server.js +136 -25
- package/src/services/discovery.js +19 -40
- package/src/services/executor.js +86 -25
- package/src/services/memoize.js +3 -25
- package/src/services/registry.js +6 -0
- package/src/template/precompile.js +8 -19
package/core/errors/http.js
CHANGED
|
@@ -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
|
+
|
package/core/errors/index.js
CHANGED
|
@@ -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,
|
package/core/errors/normalize.js
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { WebspressoError } = require('./base');
|
|
8
|
-
const {
|
|
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
|
|
200
|
+
expose,
|
|
115
201
|
});
|
|
116
202
|
|
|
117
203
|
if (err.route) httpErr.route = err.route;
|
package/core/events/discovery.js
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -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
|
+
};
|
|
@@ -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
|
+
};
|