@blamejs/core 0.5.10 → 0.5.12
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/CHANGELOG.md +2 -0
- package/index.js +2 -0
- package/lib/config.js +127 -0
- package/lib/middleware/index.js +2 -0
- package/lib/middleware/request-log.js +167 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.5.x
|
|
10
10
|
|
|
11
|
+
- **0.5.11** (2026-04-30) — b.config: schema-validated environment configuration
|
|
12
|
+
- **0.5.10** (2026-04-30) — b.middleware.sse: Server-Sent Events
|
|
11
13
|
- **0.5.9** (2026-04-30) — b.csv: RFC 4180 parser + serializer
|
|
12
14
|
- **0.5.8** (2026-04-30) — b.uuid: RFC 4122 v4 + RFC 9562 v7
|
|
13
15
|
- **0.5.7** (2026-04-30) — defensive validation + queue closure capture + audit context
|
package/index.js
CHANGED
|
@@ -106,6 +106,7 @@ var forms = require("./lib/forms");
|
|
|
106
106
|
var app = require("./lib/app");
|
|
107
107
|
var jobs = require("./lib/jobs");
|
|
108
108
|
var breakGlass = require("./lib/break-glass");
|
|
109
|
+
var config = require("./lib/config");
|
|
109
110
|
var csv = require("./lib/csv");
|
|
110
111
|
var uuid = require("./lib/uuid");
|
|
111
112
|
var mail = require("./lib/mail");
|
|
@@ -210,6 +211,7 @@ module.exports = {
|
|
|
210
211
|
createApp: app.createApp,
|
|
211
212
|
jobs: jobs,
|
|
212
213
|
breakGlass: breakGlass,
|
|
214
|
+
config: config,
|
|
213
215
|
csv: csv,
|
|
214
216
|
uuid: uuid,
|
|
215
217
|
mail: mail,
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* config — schema-validated environment configuration.
|
|
4
|
+
*
|
|
5
|
+
* Operators read process.env throughout their app code. A typo in the
|
|
6
|
+
* key name OR a value in the wrong shape (port="abc", flag="yas")
|
|
7
|
+
* surfaces three days later as a mysterious 500. This primitive validates
|
|
8
|
+
* env at boot via b.safeSchema so the app refuses to start with broken
|
|
9
|
+
* config.
|
|
10
|
+
*
|
|
11
|
+
* var config = b.config.create({
|
|
12
|
+
* schema: b.safeSchema.object({
|
|
13
|
+
* NODE_ENV: b.safeSchema.enum_(["development", "test", "production"]),
|
|
14
|
+
* PORT: b.config.coerce.number().default(3000),
|
|
15
|
+
* LOG_LEVEL: b.safeSchema.enum_(["debug", "info", "warn", "error"]).default("info"),
|
|
16
|
+
* SESSION_SECRET: b.safeSchema.string().min(32),
|
|
17
|
+
* DATABASE_URL: b.safeSchema.string().url(),
|
|
18
|
+
* REDIS_URL: b.safeSchema.string().url().optional(),
|
|
19
|
+
* FEATURE_X: b.config.coerce.boolean().default(false),
|
|
20
|
+
* }),
|
|
21
|
+
* // env: process.env (default) — operators in tests pass a fake object
|
|
22
|
+
* // redactKeys: ["SESSION_SECRET", "DATABASE_URL"] — never log these
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* config.value → the validated, typed value object
|
|
26
|
+
* config.value.PORT → 3000 (Number, not "3000")
|
|
27
|
+
* config.boot() → throws ConfigError on validation failure;
|
|
28
|
+
* returns the validated value otherwise
|
|
29
|
+
*
|
|
30
|
+
* The factory immediately runs validation — the throw at create() time
|
|
31
|
+
* is intentional. Operators want config errors at app boot, not at
|
|
32
|
+
* the first request that touches the broken value.
|
|
33
|
+
*/
|
|
34
|
+
var safeSchema = require("./safe-schema");
|
|
35
|
+
var validateOpts = require("./validate-opts");
|
|
36
|
+
var { defineClass } = require("./framework-error");
|
|
37
|
+
|
|
38
|
+
var REDACT_MASK = "[REDACTED]";
|
|
39
|
+
|
|
40
|
+
var ConfigError = defineClass("ConfigError", { alwaysPermanent: true });
|
|
41
|
+
|
|
42
|
+
// Coercion shapes most operators want for env values: number, integer,
|
|
43
|
+
// boolean — env is always a string at the source so coerce. Operators
|
|
44
|
+
// who want stricter parsing chain .refine() or use raw schemas.
|
|
45
|
+
var coerce = {
|
|
46
|
+
number: function () {
|
|
47
|
+
return safeSchema.preprocess(function (v) {
|
|
48
|
+
if (v === undefined || v === null || v === "") return v;
|
|
49
|
+
var n = Number(v);
|
|
50
|
+
return isNaN(n) ? v : n;
|
|
51
|
+
}, safeSchema.number());
|
|
52
|
+
},
|
|
53
|
+
boolean: function () {
|
|
54
|
+
return safeSchema.preprocess(function (v) {
|
|
55
|
+
if (typeof v === "boolean") return v;
|
|
56
|
+
if (v === "1" || v === "true" || v === "yes") return true;
|
|
57
|
+
if (v === "0" || v === "false" || v === "no") return false;
|
|
58
|
+
return v; // pass through — schema rejects non-boolean
|
|
59
|
+
}, safeSchema.boolean());
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function create(opts) {
|
|
64
|
+
if (!opts || typeof opts !== "object") {
|
|
65
|
+
throw new ConfigError("config/bad-opts",
|
|
66
|
+
"create: opts is required (must include opts.schema)");
|
|
67
|
+
}
|
|
68
|
+
validateOpts(opts, ["schema", "env", "redactKeys"], "config.create");
|
|
69
|
+
if (!opts.schema || typeof opts.schema.parse !== "function") {
|
|
70
|
+
throw new ConfigError("config/bad-schema",
|
|
71
|
+
"create: opts.schema must be a b.safeSchema instance (built via b.safeSchema.object({...}))");
|
|
72
|
+
}
|
|
73
|
+
var env = opts.env || process.env;
|
|
74
|
+
if (env !== process.env && (typeof env !== "object" || env === null)) {
|
|
75
|
+
throw new ConfigError("config/bad-env",
|
|
76
|
+
"create: opts.env must be an object (default process.env)");
|
|
77
|
+
}
|
|
78
|
+
var redactKeys = Array.isArray(opts.redactKeys) ? opts.redactKeys.slice() : [];
|
|
79
|
+
for (var i = 0; i < redactKeys.length; i++) {
|
|
80
|
+
if (typeof redactKeys[i] !== "string" || redactKeys[i].length === 0) {
|
|
81
|
+
throw new ConfigError("config/bad-redact-keys",
|
|
82
|
+
"create: redactKeys[" + i + "] must be a non-empty string");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Filter env to a plain object — process.env's prototype chain
|
|
86
|
+
// includes inherited Object.prototype keys we don't want to validate.
|
|
87
|
+
var input = {};
|
|
88
|
+
for (var k in env) {
|
|
89
|
+
if (Object.prototype.hasOwnProperty.call(env, k)) input[k] = env[k];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
var result = opts.schema.safeParse(input);
|
|
93
|
+
if (!result.ok) {
|
|
94
|
+
var msg = "config validation failed:\n";
|
|
95
|
+
for (var ei = 0; ei < result.errors.length; ei++) {
|
|
96
|
+
var err = result.errors[ei];
|
|
97
|
+
msg += " - " + err.path.join(".") + ": " + err.message + "\n";
|
|
98
|
+
}
|
|
99
|
+
throw new ConfigError("config/validation-failed", msg);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
var value = result.value;
|
|
103
|
+
|
|
104
|
+
function redactedView() {
|
|
105
|
+
// For logging the validated config without leaking secrets — useful
|
|
106
|
+
// at boot ("loaded config: { NODE_ENV: 'production', PORT: 3000, ... }").
|
|
107
|
+
var out = {};
|
|
108
|
+
for (var k in value) {
|
|
109
|
+
if (!Object.prototype.hasOwnProperty.call(value, k)) continue;
|
|
110
|
+
out[k] = redactKeys.indexOf(k) !== -1 ? REDACT_MASK : value[k];
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
value: value,
|
|
117
|
+
get: function (key) { return value[key]; },
|
|
118
|
+
has: function (key) { return Object.prototype.hasOwnProperty.call(value, key); },
|
|
119
|
+
redacted: redactedView,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
create: create,
|
|
125
|
+
ConfigError: ConfigError,
|
|
126
|
+
coerce: coerce,
|
|
127
|
+
};
|
package/lib/middleware/index.js
CHANGED
|
@@ -31,6 +31,7 @@ module.exports = {
|
|
|
31
31
|
compression: require("./compression").create,
|
|
32
32
|
cspNonce: require("./csp-nonce").create,
|
|
33
33
|
sse: require("./sse").create,
|
|
34
|
+
requestLog: require("./request-log").create,
|
|
34
35
|
apiEncrypt: require("./api-encrypt"),
|
|
35
36
|
|
|
36
37
|
// Module exports for advanced use (constants, raw factory access)
|
|
@@ -49,6 +50,7 @@ module.exports = {
|
|
|
49
50
|
compression: require("./compression"),
|
|
50
51
|
cspNonce: require("./csp-nonce"),
|
|
51
52
|
sse: require("./sse"),
|
|
53
|
+
requestLog: require("./request-log"),
|
|
52
54
|
apiEncrypt: require("./api-encrypt"),
|
|
53
55
|
},
|
|
54
56
|
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* request-log — HTTP access-log middleware. Captures method, path,
|
|
4
|
+
* status, duration, response bytes, requestId, actor IP, user-agent
|
|
5
|
+
* and emits one structured log entry per request via b.log.
|
|
6
|
+
*
|
|
7
|
+
* router.use(b.middleware.requestLog({
|
|
8
|
+
* logger: b.log.boot("http"), // any b.log instance
|
|
9
|
+
* skipPaths: ["/healthz", /^\/static/],
|
|
10
|
+
* trustProxy: true, // honors X-Forwarded-For
|
|
11
|
+
* levelFn: function (status) { // default: 5xx=error, 4xx=warn, else info
|
|
12
|
+
* return status >= 500 ? "error" : status >= 400 ? "warn" : "info";
|
|
13
|
+
* },
|
|
14
|
+
* fields: ["method", "path", "status", "durationMs",
|
|
15
|
+
* "actorIp", "userAgent", "requestId", "bytes"],
|
|
16
|
+
* }));
|
|
17
|
+
*
|
|
18
|
+
* The middleware adopts `b.requestHelpers.captureResponseStatus` to
|
|
19
|
+
* read the final status reliably (handlers that call `res.writeHead`
|
|
20
|
+
* vs `res.statusCode = ...` vs `res.status(...).send(...)` all work).
|
|
21
|
+
*
|
|
22
|
+
* trustProxy gates X-Forwarded-For consumption — same boundary as the
|
|
23
|
+
* rest of the framework. Default false; operators behind a sanitizing
|
|
24
|
+
* reverse proxy opt in.
|
|
25
|
+
*
|
|
26
|
+
* Emits at log-level keyed off response status by default. Operators
|
|
27
|
+
* who want one-level-fits-all pass a static string for `level` (e.g.
|
|
28
|
+
* "debug" to keep access logs out of production stdout) or a function
|
|
29
|
+
* for fully custom logic (e.g. "warn" only on slow-path requests).
|
|
30
|
+
*/
|
|
31
|
+
var requestHelpers = require("../request-helpers");
|
|
32
|
+
var validateOpts = require("../validate-opts");
|
|
33
|
+
|
|
34
|
+
var DEFAULT_FIELDS = [
|
|
35
|
+
"method", "path", "status", "durationMs", "bytes",
|
|
36
|
+
"actorIp", "userAgent", "requestId",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function _defaultLevel(status) {
|
|
40
|
+
if (status >= 500) return "error";
|
|
41
|
+
if (status >= 400) return "warn";
|
|
42
|
+
return "info";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function create(opts) {
|
|
46
|
+
opts = opts || {};
|
|
47
|
+
validateOpts(opts, [
|
|
48
|
+
"logger", "skipPaths", "trustProxy", "level", "levelFn", "fields",
|
|
49
|
+
], "middleware.requestLog");
|
|
50
|
+
var logger = opts.logger;
|
|
51
|
+
if (!logger || typeof logger.info !== "function") {
|
|
52
|
+
throw new Error("middleware.requestLog: opts.logger must be a b.log instance " +
|
|
53
|
+
"(call b.log.boot(...) or b.log.create({...}))");
|
|
54
|
+
}
|
|
55
|
+
var skipPaths = Array.isArray(opts.skipPaths) ? opts.skipPaths.slice() : [];
|
|
56
|
+
for (var i = 0; i < skipPaths.length; i++) {
|
|
57
|
+
if (typeof skipPaths[i] !== "string" && !(skipPaths[i] instanceof RegExp)) {
|
|
58
|
+
throw new Error("middleware.requestLog: skipPaths[" + i + "] must be a string prefix or RegExp");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
|
|
62
|
+
? opts.trustProxy : false;
|
|
63
|
+
var levelFn;
|
|
64
|
+
if (typeof opts.levelFn === "function") {
|
|
65
|
+
levelFn = opts.levelFn;
|
|
66
|
+
} else if (typeof opts.level === "string") {
|
|
67
|
+
var fixedLevel = opts.level;
|
|
68
|
+
levelFn = function () { return fixedLevel; };
|
|
69
|
+
} else {
|
|
70
|
+
levelFn = _defaultLevel;
|
|
71
|
+
}
|
|
72
|
+
var fields = Array.isArray(opts.fields) && opts.fields.length > 0
|
|
73
|
+
? opts.fields.slice()
|
|
74
|
+
: DEFAULT_FIELDS;
|
|
75
|
+
|
|
76
|
+
function _shouldSkip(req) {
|
|
77
|
+
var path = req.pathname || (req.url || "").split("?")[0];
|
|
78
|
+
for (var i = 0; i < skipPaths.length; i++) {
|
|
79
|
+
var entry = skipPaths[i];
|
|
80
|
+
if (typeof entry === "string") { if (path.indexOf(entry) === 0) return true; }
|
|
81
|
+
else if (entry.test(path)) return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return function requestLog(req, res, next) {
|
|
87
|
+
if (_shouldSkip(req)) return next();
|
|
88
|
+
var startedAt = process.hrtime ? process.hrtime() : null;
|
|
89
|
+
var startedMs = Date.now();
|
|
90
|
+
var bytes = 0;
|
|
91
|
+
var statusFromWriteHead = null;
|
|
92
|
+
var emitted = false;
|
|
93
|
+
|
|
94
|
+
// Tally bytes off res.write / res.end and read the final status
|
|
95
|
+
// when end fires. Inlined here (rather than composing
|
|
96
|
+
// captureResponseStatus + a separate byte-counter) so the log
|
|
97
|
+
// entry sees the fully-populated bytes counter — wrap order
|
|
98
|
+
// matters: bytes must be incremented BEFORE the log emit.
|
|
99
|
+
var origWrite = res.write;
|
|
100
|
+
var origEnd = res.end;
|
|
101
|
+
var origWriteHead = res.writeHead;
|
|
102
|
+
res.writeHead = function (s) {
|
|
103
|
+
statusFromWriteHead = s;
|
|
104
|
+
return origWriteHead.apply(res, arguments);
|
|
105
|
+
};
|
|
106
|
+
res.write = function (chunk, enc, cb) {
|
|
107
|
+
if (chunk != null) {
|
|
108
|
+
var len = Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk), enc || "utf8");
|
|
109
|
+
bytes += len;
|
|
110
|
+
}
|
|
111
|
+
return origWrite.call(res, chunk, enc, cb);
|
|
112
|
+
};
|
|
113
|
+
res.end = function (chunk, enc, cb) {
|
|
114
|
+
if (chunk != null) {
|
|
115
|
+
var len = Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk), enc || "utf8");
|
|
116
|
+
bytes += len;
|
|
117
|
+
}
|
|
118
|
+
_emit();
|
|
119
|
+
return origEnd.call(res, chunk, enc, cb);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function _emit() {
|
|
123
|
+
if (emitted) return;
|
|
124
|
+
emitted = true;
|
|
125
|
+
var statusCode = statusFromWriteHead != null
|
|
126
|
+
? statusFromWriteHead
|
|
127
|
+
: (typeof res.statusCode === "number" ? res.statusCode : 200);
|
|
128
|
+
var durationMs;
|
|
129
|
+
if (startedAt && process.hrtime) {
|
|
130
|
+
var d = process.hrtime(startedAt);
|
|
131
|
+
durationMs = (d[0] * 1000) + (d[1] / 1e6);
|
|
132
|
+
} else {
|
|
133
|
+
durationMs = Date.now() - startedMs;
|
|
134
|
+
}
|
|
135
|
+
var entry = {};
|
|
136
|
+
var actor = requestHelpers.extractActorContext(req, {
|
|
137
|
+
ip: requestHelpers.clientIp(req, { trustProxy: trustProxy }),
|
|
138
|
+
});
|
|
139
|
+
var src = {
|
|
140
|
+
method: req.method,
|
|
141
|
+
path: req.pathname || (req.url || "").split("?")[0],
|
|
142
|
+
status: statusCode,
|
|
143
|
+
durationMs: Math.round(durationMs * 100) / 100,
|
|
144
|
+
bytes: bytes,
|
|
145
|
+
actorIp: actor.ip,
|
|
146
|
+
userAgent: actor.userAgent,
|
|
147
|
+
requestId: actor.requestId,
|
|
148
|
+
sessionId: actor.sessionId,
|
|
149
|
+
userId: actor.userId,
|
|
150
|
+
route: actor.route,
|
|
151
|
+
};
|
|
152
|
+
for (var fi = 0; fi < fields.length; fi++) {
|
|
153
|
+
var f = fields[fi];
|
|
154
|
+
if (Object.prototype.hasOwnProperty.call(src, f)) entry[f] = src[f];
|
|
155
|
+
}
|
|
156
|
+
var level = levelFn(statusCode, req, res);
|
|
157
|
+
var fn = typeof logger[level] === "function" ? logger[level] : logger.info;
|
|
158
|
+
try { fn.call(logger, "http", entry); } catch (_e) { /* never let log-emit failure crash the response */ }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return next();
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
create: create,
|
|
167
|
+
};
|