@blamejs/core 0.5.11 → 0.5.13
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/lib/middleware/index.js +2 -0
- package/lib/middleware/request-log.js +167 -0
- package/lib/testing.js +161 -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.12** (2026-04-30) — b.middleware.requestLog: HTTP access-log middleware
|
|
12
|
+
- **0.5.11** (2026-04-30) — b.config: schema-validated environment configuration
|
|
11
13
|
- **0.5.10** (2026-04-30) — b.middleware.sse: Server-Sent Events
|
|
12
14
|
- **0.5.9** (2026-04-30) — b.csv: RFC 4180 parser + serializer
|
|
13
15
|
- **0.5.8** (2026-04-30) — b.uuid: RFC 4122 v4 + RFC 9562 v7
|
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
|
+
};
|
package/lib/testing.js
CHANGED
|
@@ -547,6 +547,165 @@ function listenOnRandomPort(server, host) {
|
|
|
547
547
|
// Standalone fake of @opentelemetry/api's minimal subset that
|
|
548
548
|
// b.tracing actually consumes. No framework primitive owns this.
|
|
549
549
|
|
|
550
|
+
// ---- request(target) — supertest-style chainable HTTP test helper ----
|
|
551
|
+
//
|
|
552
|
+
// var res = await b.testing.request(router)
|
|
553
|
+
// .post("/api/widget")
|
|
554
|
+
// .set("X-Request-Id", "abc")
|
|
555
|
+
// .send({ name: "alpha" })
|
|
556
|
+
// .expect(200);
|
|
557
|
+
//
|
|
558
|
+
// res.status, res.headers, res.body (Buffer), res.json (parsed if applicable)
|
|
559
|
+
//
|
|
560
|
+
// Accepts:
|
|
561
|
+
// - a b.router instance (uses .handle(req, res))
|
|
562
|
+
// - a request listener function (req, res) => void
|
|
563
|
+
// - an http.Server / https.Server (used as-is)
|
|
564
|
+
//
|
|
565
|
+
// The framework spins up a real http.Server on an ephemeral port so
|
|
566
|
+
// the request flows through the full Node http stack — same code path
|
|
567
|
+
// production traffic takes. Server is closed automatically when the
|
|
568
|
+
// promise resolves or rejects.
|
|
569
|
+
function request(target) {
|
|
570
|
+
var http = require("node:http");
|
|
571
|
+
// Resolve target → request listener
|
|
572
|
+
var server;
|
|
573
|
+
var ownsServer = false;
|
|
574
|
+
if (target && typeof target.handle === "function") {
|
|
575
|
+
server = http.createServer(function (req, res) {
|
|
576
|
+
Promise.resolve(target.handle(req, res)).catch(function (err) {
|
|
577
|
+
if (!res.headersSent) res.writeHead(500, { "Content-Type": "text/plain" });
|
|
578
|
+
try { res.end((err && err.message) || "Internal Server Error"); } catch (_e) { /* response may already be ended */ }
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
ownsServer = true;
|
|
582
|
+
} else if (typeof target === "function") {
|
|
583
|
+
server = http.createServer(target);
|
|
584
|
+
ownsServer = true;
|
|
585
|
+
} else if (target && typeof target.listen === "function" && typeof target.close === "function") {
|
|
586
|
+
server = target;
|
|
587
|
+
ownsServer = false;
|
|
588
|
+
} else {
|
|
589
|
+
throw new Error("b.testing.request: target must be a b.router, a (req,res)=>void function, or an http.Server");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function _start(method, path) {
|
|
593
|
+
var headers = {};
|
|
594
|
+
var body = null;
|
|
595
|
+
var expectations = [];
|
|
596
|
+
|
|
597
|
+
var chain = {
|
|
598
|
+
set: function (k, v) {
|
|
599
|
+
if (typeof k === "object" && k !== null) Object.assign(headers, k);
|
|
600
|
+
else headers[k] = v;
|
|
601
|
+
return chain;
|
|
602
|
+
},
|
|
603
|
+
send: function (b) {
|
|
604
|
+
if (b == null) { body = null; return chain; }
|
|
605
|
+
if (Buffer.isBuffer(b) || typeof b === "string") {
|
|
606
|
+
body = b;
|
|
607
|
+
} else {
|
|
608
|
+
body = JSON.stringify(b);
|
|
609
|
+
if (!headers["Content-Type"] && !headers["content-type"]) {
|
|
610
|
+
headers["Content-Type"] = "application/json";
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return chain;
|
|
614
|
+
},
|
|
615
|
+
expect: function (statusOrAssertion) {
|
|
616
|
+
expectations.push(statusOrAssertion);
|
|
617
|
+
return chain;
|
|
618
|
+
},
|
|
619
|
+
then: function (onFulfilled, onRejected) {
|
|
620
|
+
return _execute().then(onFulfilled, onRejected);
|
|
621
|
+
},
|
|
622
|
+
catch: function (onRejected) {
|
|
623
|
+
return _execute().catch(onRejected);
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
function _execute() {
|
|
628
|
+
return new Promise(function (resolve, reject) {
|
|
629
|
+
var listenP;
|
|
630
|
+
if (ownsServer) listenP = listenOnRandomPort(server);
|
|
631
|
+
else listenP = Promise.resolve(server.address() ? server.address().port : null);
|
|
632
|
+
|
|
633
|
+
listenP.then(function (port) {
|
|
634
|
+
var reqOpts = {
|
|
635
|
+
host: "127.0.0.1",
|
|
636
|
+
port: port,
|
|
637
|
+
method: method,
|
|
638
|
+
path: path,
|
|
639
|
+
headers: headers,
|
|
640
|
+
};
|
|
641
|
+
var nodeReq = http.request(reqOpts, function (nodeRes) {
|
|
642
|
+
var chunks = [];
|
|
643
|
+
nodeRes.on("data", function (c) { chunks.push(c); });
|
|
644
|
+
nodeRes.on("end", function () {
|
|
645
|
+
var bodyBuf = Buffer.concat(chunks);
|
|
646
|
+
var bodyText = bodyBuf.toString("utf8");
|
|
647
|
+
var json = null;
|
|
648
|
+
var ct = nodeRes.headers["content-type"] || "";
|
|
649
|
+
if (ct.indexOf("application/json") !== -1) {
|
|
650
|
+
try { json = JSON.parse(bodyText); } catch (_e) { /* leave json null */ }
|
|
651
|
+
}
|
|
652
|
+
var result = {
|
|
653
|
+
status: nodeRes.statusCode,
|
|
654
|
+
headers: nodeRes.headers,
|
|
655
|
+
body: bodyBuf,
|
|
656
|
+
text: bodyText,
|
|
657
|
+
json: json,
|
|
658
|
+
};
|
|
659
|
+
try {
|
|
660
|
+
for (var i = 0; i < expectations.length; i++) {
|
|
661
|
+
var exp = expectations[i];
|
|
662
|
+
if (typeof exp === "number") {
|
|
663
|
+
if (result.status !== exp) {
|
|
664
|
+
throw new Error("expect(" + exp + ") got status " + result.status +
|
|
665
|
+
" body: " + bodyText.slice(0, 200));
|
|
666
|
+
}
|
|
667
|
+
} else if (typeof exp === "function") {
|
|
668
|
+
exp(result);
|
|
669
|
+
} else {
|
|
670
|
+
throw new Error("expect: argument must be a number or function");
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
resolve(result);
|
|
674
|
+
} catch (e) {
|
|
675
|
+
reject(e);
|
|
676
|
+
} finally {
|
|
677
|
+
if (ownsServer) try { server.close(); } catch (_e) { /* server may already be closed */ }
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
nodeRes.on("error", function (err) {
|
|
681
|
+
if (ownsServer) try { server.close(); } catch (_e) { /* */ }
|
|
682
|
+
reject(err);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
nodeReq.on("error", function (err) {
|
|
686
|
+
if (ownsServer) try { server.close(); } catch (_e) { /* */ }
|
|
687
|
+
reject(err);
|
|
688
|
+
});
|
|
689
|
+
if (body != null) nodeReq.write(body);
|
|
690
|
+
nodeReq.end();
|
|
691
|
+
}, reject);
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return chain;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
get: function (p) { return _start("GET", p); },
|
|
700
|
+
post: function (p) { return _start("POST", p); },
|
|
701
|
+
put: function (p) { return _start("PUT", p); },
|
|
702
|
+
patch: function (p) { return _start("PATCH", p); },
|
|
703
|
+
delete: function (p) { return _start("DELETE", p); },
|
|
704
|
+
head: function (p) { return _start("HEAD", p); },
|
|
705
|
+
options:function (p) { return _start("OPTIONS",p); },
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
550
709
|
function makeFakeOtelApi() {
|
|
551
710
|
var spans = [];
|
|
552
711
|
var activeSpan = null;
|
|
@@ -615,6 +774,8 @@ module.exports = {
|
|
|
615
774
|
// Async test helpers
|
|
616
775
|
runMiddleware: runMiddleware,
|
|
617
776
|
waitFor: waitFor,
|
|
777
|
+
// Chainable HTTP request helper (supertest-style)
|
|
778
|
+
request: request,
|
|
618
779
|
// Class + constants
|
|
619
780
|
TestingError: TestingError,
|
|
620
781
|
DEFAULTS: DEFAULTS,
|