kaelum 1.2.0 → 1.3.1

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.
@@ -0,0 +1,139 @@
1
+ // core/errorHandler.js
2
+ // Generic error handling middleware factory for Kaelum.
3
+ //
4
+ // Exports:
5
+ // - module.exports = errorHandlerFactory
6
+ // - module.exports.errorHandler = errorHandlerFactory
7
+ //
8
+ // Usage:
9
+ // const errorHandler = require('./core/errorHandler');
10
+ // app.use(errorHandler({ exposeStack: false }));
11
+ //
12
+ // Options:
13
+ // - exposeStack: boolean (default false) -> include stack trace in JSON when true
14
+ // - logger: function(err, req, info?) optional -> custom logger
15
+ // - onError: function(err, req, res) optional -> hook called before sending response
16
+ //
17
+ // Response JSON shape:
18
+ // { error: { message, code, ...(stack) } }
19
+ // Status chosen from err.status || err.statusCode || 500
20
+
21
+ /**
22
+ * @param {Object} options
23
+ * @param {boolean} [options.exposeStack=false] - include stack trace in responses
24
+ * @param {Function} [options.logger] - optional logger function: logger(err, req, info)
25
+ * @param {Function} [options.onError] - hook called before sending response: onError(err, req, res)
26
+ * @returns {Function} express error-handling middleware (err, req, res, next)
27
+ */
28
+ function errorHandlerFactory(options = {}) {
29
+ const { exposeStack = false, logger = null, onError = null } = options || {};
30
+
31
+ /**
32
+ * Default logger used when no custom logger is provided.
33
+ * @param {Error|any} err
34
+ * @param {Object} req
35
+ */
36
+ function defaultLog(err, req) {
37
+ const status =
38
+ err && (err.status || err.statusCode)
39
+ ? err.status || err.statusCode
40
+ : 500;
41
+ if (status >= 500) {
42
+ if (err && err.stack) console.error(err.stack);
43
+ else console.error(err);
44
+ } else {
45
+ if (err && err.message) console.warn(err.message);
46
+ else console.warn(err);
47
+ }
48
+ }
49
+
50
+ return function errorHandler(err, req, res, next) {
51
+ // If headers already sent, fall back to default express handler
52
+ if (res.headersSent) {
53
+ return next(err);
54
+ }
55
+
56
+ // normalize non-error values (e.g., throw "string")
57
+ const normalizedErr =
58
+ err instanceof Error
59
+ ? err
60
+ : new Error(typeof err === "string" ? err : "Unknown error");
61
+
62
+ // determine HTTP status
63
+ const status =
64
+ err && (err.status || err.statusCode)
65
+ ? err.status || err.statusCode
66
+ : 500;
67
+
68
+ // prepare payload
69
+ const payload = {
70
+ error: {
71
+ message:
72
+ (err && (err.message || err.msg)) ||
73
+ normalizedErr.message ||
74
+ "Internal Server Error",
75
+ code: err && err.code ? err.code : "INTERNAL_ERROR",
76
+ },
77
+ };
78
+
79
+ if (exposeStack && normalizedErr && normalizedErr.stack) {
80
+ payload.error.stack = normalizedErr.stack;
81
+ }
82
+
83
+ // logging: prefer custom logger if provided
84
+ try {
85
+ if (typeof logger === "function") {
86
+ // allow custom logger to receive (err, req, { status })
87
+ logger(normalizedErr, req, { status });
88
+ } else {
89
+ defaultLog(normalizedErr, req);
90
+ }
91
+ } catch (logErr) {
92
+ // don't crash if logger fails
93
+ console.error("Kaelum errorHandler: logger threw an error", logErr);
94
+ }
95
+
96
+ // onError hook (e.g., report to external service)
97
+ try {
98
+ if (typeof onError === "function") {
99
+ try {
100
+ onError(normalizedErr, req, res);
101
+ } catch (hookErr) {
102
+ // don't block response if hook fails
103
+ console.error("Kaelum errorHandler: onError hook threw", hookErr);
104
+ }
105
+ }
106
+ } catch (_) {
107
+ // ignore
108
+ }
109
+
110
+ // Respond according to Accept header: JSON preferred, fallback to text/html
111
+ if (req && req.accepts && req.accepts("html") && !req.accepts("json")) {
112
+ // simple HTML response for browsers preferring HTML
113
+ const title = `Error ${status}`;
114
+ const body = `
115
+ <!doctype html>
116
+ <html>
117
+ <head><meta charset="utf-8"/><title>${title}</title></head>
118
+ <body>
119
+ <h1>${title}</h1>
120
+ <p>${payload.error.message}</p>
121
+ ${
122
+ exposeStack && payload.error.stack
123
+ ? `<pre>${payload.error.stack}</pre>`
124
+ : ""
125
+ }
126
+ </body>
127
+ </html>`;
128
+ res.status(status).type("html").send(body);
129
+ return;
130
+ }
131
+
132
+ // default: JSON response
133
+ res.status(status).json(payload);
134
+ };
135
+ }
136
+
137
+ // Export both default and named to keep compatibility with different import styles
138
+ module.exports = errorHandlerFactory;
139
+ module.exports.errorHandler = errorHandlerFactory;
@@ -0,0 +1,204 @@
1
+ // core/healthCheck.js
2
+ // Kaelum - health check helper
3
+ //
4
+ // Exports a factory function to register a health (liveness/readiness) endpoint.
5
+ //
6
+ // Usage examples:
7
+ // const registerHealth = require('./core/healthCheck');
8
+ // // simple
9
+ // registerHealth(app);
10
+ // // with options
11
+ // registerHealth(app, {
12
+ // path: '/health',
13
+ // readinessCheck: async () => {
14
+ // // custom checks (e.g. DB ping). Return { ok: true, details: { ... } } or { ok: false, details: {...} }
15
+ // const ok = await db.ping();
16
+ // return { ok, details: { db: ok } };
17
+ // },
18
+ // include: { uptime: true, pid: true, env: true, timestamp: true },
19
+ // replace: true
20
+ // });
21
+
22
+ const DEFAULT_PATH = "/health";
23
+
24
+ /**
25
+ * @typedef {Object} HealthOptions
26
+ * @property {string} [path] - Endpoint path (default '/health')
27
+ * @property {string} [method] - HTTP method for health endpoint (default 'get')
28
+ * @property {boolean} [replace] - If true and a previous Kaelum-installed endpoint exists, replace it (default false)
29
+ * @property {Function} [readinessCheck] - Optional async function that returns { ok: boolean, details?: Object }
30
+ * @property {Object} [include] - Which fields to include in payload. Defaults to all true.
31
+ * @property {boolean} [include.uptime]
32
+ * @property {boolean} [include.pid]
33
+ * @property {boolean} [include.env]
34
+ * @property {boolean} [include.timestamp]
35
+ * @property {boolean} [include.metrics] - reserved for future metrics (default false)
36
+ */
37
+
38
+ /**
39
+ * Check if the same route path+method already exists in the app router.
40
+ * Only inspects Kaelum-installed layers conservatively.
41
+ * @param {Object} app - express app
42
+ * @param {string} path
43
+ * @param {string} method
44
+ * @returns {boolean}
45
+ */
46
+ function routeExists(app, path, method) {
47
+ try {
48
+ if (!app || !app._router || !Array.isArray(app._router.stack)) return false;
49
+ return app._router.stack.some((layer) => {
50
+ if (!layer || !layer.route) return false;
51
+ if (layer.route.path !== path) return false;
52
+ return (
53
+ !!layer.route.methods && !!layer.route.methods[method.toLowerCase()]
54
+ );
55
+ });
56
+ } catch (e) {
57
+ // if internal inspection fails, be conservative and return false
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Register a health check endpoint on the provided Express/Kaelum app.
64
+ * @param {Object} app - Express/Kaelum app instance
65
+ * @param {HealthOptions|string} [opts] - options or a string path
66
+ * @returns {Function} the handler function created (useful for tests)
67
+ */
68
+ function registerHealth(app, opts = {}) {
69
+ if (!app || typeof app.get !== "function") {
70
+ throw new Error("Invalid app instance: cannot register health check");
71
+ }
72
+
73
+ // allow shorthand: passing a string path
74
+ const options =
75
+ typeof opts === "string"
76
+ ? { path: opts }
77
+ : Object.assign(
78
+ {
79
+ path: DEFAULT_PATH,
80
+ method: "get",
81
+ replace: false,
82
+ readinessCheck: null,
83
+ include: {
84
+ uptime: true,
85
+ pid: true,
86
+ env: true,
87
+ timestamp: true,
88
+ metrics: false,
89
+ },
90
+ },
91
+ opts || {}
92
+ );
93
+
94
+ // normalize path and method
95
+ let p =
96
+ options.path && typeof options.path === "string"
97
+ ? options.path
98
+ : DEFAULT_PATH;
99
+ if (!p.startsWith("/")) p = "/" + p;
100
+ const method = (options.method || "get").toLowerCase();
101
+
102
+ // if route exists and replace is false -> skip registration
103
+ if (routeExists(app, p, method) && !options.replace) {
104
+ return null;
105
+ }
106
+
107
+ // if replace requested, attempt to remove prior Kaelum-installed handler
108
+ if (options.replace) {
109
+ try {
110
+ // remove layers that match exactly the route path and method
111
+ if (app._router && Array.isArray(app._router.stack)) {
112
+ app._router.stack = app._router.stack.filter((layer) => {
113
+ if (!layer || !layer.route) return true; // keep non-route layers
114
+ if (layer.route.path !== p) return true; // keep other routes
115
+ // keep only layers that do not match the method we want to replace
116
+ return !layer.route.methods || !layer.route.methods[method];
117
+ });
118
+ }
119
+ } catch (e) {
120
+ // ignore removal errors - continue to register anyway
121
+ }
122
+ } else {
123
+ // if route exists and replace === false we already returned above
124
+ }
125
+
126
+ /**
127
+ * The handler performs an optional readinessCheck. If readinessCheck returns
128
+ * { ok: false } then status 503 is sent, otherwise 200.
129
+ * readinessCheck may be synchronous or asynchronous.
130
+ */
131
+ const handler = async (req, res) => {
132
+ // default payload base
133
+ const payload = {
134
+ status: "OK",
135
+ };
136
+
137
+ // include optional fields
138
+ const inc = options.include || {};
139
+ if (inc.uptime) payload.uptime = process.uptime();
140
+ if (inc.pid) payload.pid = process.pid;
141
+ if (inc.env) payload.env = process.env.NODE_ENV || "development";
142
+ if (inc.timestamp) payload.timestamp = Date.now();
143
+
144
+ // readiness check (optional)
145
+ if (typeof options.readinessCheck === "function") {
146
+ try {
147
+ const result = await Promise.resolve(options.readinessCheck(req));
148
+ // result expected to be { ok: boolean, details?: object } or boolean
149
+ let ok = true;
150
+ let details = undefined;
151
+ if (typeof result === "boolean") {
152
+ ok = result;
153
+ } else if (result && typeof result === "object") {
154
+ ok = !!result.ok;
155
+ details = result.details;
156
+ } else {
157
+ // unrecognized result -> consider as ok
158
+ ok = true;
159
+ }
160
+
161
+ if (!ok) {
162
+ payload.status = "FAIL";
163
+ if (details) payload.details = details;
164
+ return res.status(503).json(payload);
165
+ }
166
+ } catch (err) {
167
+ // readiness check threw -> respond 503 with error info (don't expose stack)
168
+ payload.status = "FAIL";
169
+ payload.details = {
170
+ message: err && err.message ? err.message : "readiness check error",
171
+ };
172
+ return res.status(503).json(payload);
173
+ }
174
+ }
175
+
176
+ // success
177
+ return res.status(200).json(payload);
178
+ };
179
+
180
+ // register route on app
181
+ try {
182
+ // attach a marker so we can detect Kaelum-installed static handlers if needed
183
+ app[method](p, handler);
184
+ } catch (e) {
185
+ throw new Error(
186
+ `Failed to register health route ${method.toUpperCase()} ${p}: ${
187
+ e.message
188
+ }`
189
+ );
190
+ }
191
+
192
+ // store reference in locals for future inspection/removal
193
+ try {
194
+ app.locals = app.locals || {};
195
+ app.locals._kaelum_health = app.locals._kaelum_health || [];
196
+ app.locals._kaelum_health.push({ path: p, method, handler });
197
+ } catch (_) {
198
+ // ignore locals storage errors
199
+ }
200
+
201
+ return handler;
202
+ }
203
+
204
+ module.exports = registerHealth;
@@ -0,0 +1,177 @@
1
+ // core/redirect.js
2
+ // Kaelum - redirect helper
3
+ //
4
+ // This module provides a flexible helper to register one or many redirect routes
5
+ // in a Kaelum/Express app. It stores references to Kaelum-installed redirect
6
+ // handlers so future calls can remove only the handlers Kaelum created (safe removal).
7
+ //
8
+ // Usage examples:
9
+ // const redirect = require('./core/redirect');
10
+ // // simple single redirect
11
+ // redirect(app, '/old', '/new', 301);
12
+ //
13
+ // // as map (object)
14
+ // redirect(app, { '/old': '/new', '/a': '/b' });
15
+ //
16
+ // // as array of objects
17
+ // redirect(app, [
18
+ // { from: '/x', to: '/y', status: 302 },
19
+ // { from: '/a', to: '/b' }
20
+ // ]);
21
+ //
22
+ // // returns array of registered entries or null if nothing registered.
23
+
24
+ function normalizePath(p) {
25
+ if (!p) return "/";
26
+ if (typeof p !== "string") throw new Error("path must be a string");
27
+ return p.startsWith("/") ? p : "/" + p;
28
+ }
29
+
30
+ function normalizeStatus(s) {
31
+ const n = Number(s);
32
+ if (!Number.isFinite(n)) return 302;
33
+ // limit to common redirect codes 300-399
34
+ if (n < 300 || n >= 400) return 302;
35
+ return Math.floor(n);
36
+ }
37
+
38
+ function ensureLocals(app) {
39
+ app.locals = app.locals || {};
40
+ app.locals._kaelum_redirects = app.locals._kaelum_redirects || [];
41
+ }
42
+
43
+ /**
44
+ * Safely remove a previously registered Kaelum redirect handler for a path.
45
+ * Only removes handlers that we registered (tracked in app.locals._kaelum_redirects).
46
+ * @param {Object} app
47
+ * @param {string} path
48
+ */
49
+ function removeKaelumRedirectsForPath(app, path) {
50
+ if (!app || !app._router || !Array.isArray(app._router.stack)) return;
51
+ if (!app.locals || !Array.isArray(app.locals._kaelum_redirects)) return;
52
+
53
+ // find tracked entries for this path
54
+ const tracked = app.locals._kaelum_redirects.filter((r) => r.path === path);
55
+ if (tracked.length === 0) return;
56
+
57
+ // remove router layers whose handler matches tracked.handler
58
+ app._router.stack = app._router.stack.filter((layer) => {
59
+ // keep everything that is not a route
60
+ if (!layer || !layer.route) return true;
61
+ if (layer.route.path !== path) return true;
62
+ // check if route stack contains a tracked handler
63
+ const handlers = layer.route.stack || [];
64
+ for (const entry of tracked) {
65
+ for (const h of handlers) {
66
+ if (h && h.handle === entry.handler) {
67
+ // drop this layer (do not keep)
68
+ return false;
69
+ }
70
+ }
71
+ }
72
+ // if none of the tracked handlers matched, keep the layer
73
+ return true;
74
+ });
75
+
76
+ // remove tracked entries for that path from locals
77
+ app.locals._kaelum_redirects = app.locals._kaelum_redirects.filter(
78
+ (r) => r.path !== path
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Register a single redirect handler and track it in app.locals.
84
+ * @param {Object} app
85
+ * @param {string} from
86
+ * @param {string} to
87
+ * @param {number} status
88
+ * @returns {{ path: string, to: string, status: number }}
89
+ */
90
+ function registerSingleRedirect(app, from, to, status) {
91
+ const path = normalizePath(from);
92
+ const target = to;
93
+ const code = normalizeStatus(status);
94
+
95
+ // create handler and register GET route
96
+ const handler = function (req, res) {
97
+ res.redirect(code, target);
98
+ };
99
+
100
+ // add to express
101
+ app.get(path, handler);
102
+
103
+ // track it
104
+ ensureLocals(app);
105
+ app.locals._kaelum_redirects.push({
106
+ path,
107
+ handler,
108
+ to: target,
109
+ status: code,
110
+ });
111
+
112
+ return { path, to: target, status: code };
113
+ }
114
+
115
+ /**
116
+ * Main redirect export.
117
+ * Accepts:
118
+ * - (app, from, to, status?)
119
+ * - (app, mapObject) where mapObject is { from: to, ... }
120
+ * - (app, array) where array contains { from, to, status? } entries
121
+ *
122
+ * @param {Object} app - Express/Kaelum app
123
+ * @param {string|Object|Array} fromOrMap - path string or map/object or array of mappings
124
+ * @param {string|number} [toOrStatus] - when calling with (app, from, to, status) this is the "to" parameter
125
+ * @param {number} [maybeStatus] - optional status when calling the 4-arg form
126
+ * @returns {Array|null} list of registered redirect entries or null if none
127
+ */
128
+ function redirect(app, fromOrMap, toOrStatus, maybeStatus) {
129
+ if (!app || typeof app.get !== "function") {
130
+ throw new Error("Invalid app instance: cannot register redirect");
131
+ }
132
+
133
+ ensureLocals(app);
134
+
135
+ const registered = [];
136
+
137
+ // Helper: register one mapping (and remove previous Kaelum mapping for that path)
138
+ function applyMapping(from, to, status) {
139
+ const path = normalizePath(from);
140
+ // remove previously Kaelum-registered redirects for same path (safe removal)
141
+ removeKaelumRedirectsForPath(app, path);
142
+ // register and track
143
+ const res = registerSingleRedirect(app, path, to, status);
144
+ registered.push(res);
145
+ }
146
+
147
+ // Determine input shape
148
+ if (typeof fromOrMap === "string") {
149
+ // form: redirect(app, '/old', '/new', 302)
150
+ const from = fromOrMap;
151
+ const to = typeof toOrStatus === "string" ? toOrStatus : "/";
152
+ const status =
153
+ typeof maybeStatus !== "undefined" ? maybeStatus : toOrStatus;
154
+ applyMapping(from, to, status);
155
+ } else if (Array.isArray(fromOrMap)) {
156
+ // array of objects: [{ from, to, status }]
157
+ for (const item of fromOrMap) {
158
+ if (!item) continue;
159
+ const from = item.from || item.path || null;
160
+ const to = item.to || item.target || null;
161
+ const status = item.status || item.code || 302;
162
+ if (!from || !to) continue;
163
+ applyMapping(from, to, status);
164
+ }
165
+ } else if (fromOrMap && typeof fromOrMap === "object") {
166
+ // object map: { '/old': '/new', 'a': 'b' }
167
+ for (const k of Object.keys(fromOrMap)) {
168
+ applyMapping(k, fromOrMap[k], 302);
169
+ }
170
+ } else {
171
+ throw new Error("Invalid arguments for redirect");
172
+ }
173
+
174
+ return registered.length ? registered : null;
175
+ }
176
+
177
+ module.exports = redirect;