@blamejs/core 0.8.82 → 0.8.86

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,439 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.problemDetails
4
+ * @nav HTTP
5
+ * @title Problem Details
6
+ * @order 300
7
+ *
8
+ * @intro
9
+ * RFC 9457 Problem Details for HTTP APIs — standardized error
10
+ * response envelope with `type` / `title` / `status` / `detail` /
11
+ * `instance` plus operator-supplied extensions. Sets
12
+ * `Content-Type: application/problem+json` per RFC 9457 §3 so
13
+ * clients can branch on the content type rather than scanning ad-
14
+ * hoc JSON shapes. Supersedes RFC 7807 (obsolete).
15
+ *
16
+ * Operators wire this in three places:
17
+ * 1. `b.problemDetails.create({...})` builds a problem object
18
+ * with field validation (type URI, status range, etc.).
19
+ * 2. `b.problemDetails.respond(res, problem)` serializes + sends.
20
+ * 3. `b.problemDetails.fromError(err)` converts a thrown
21
+ * `FrameworkError` into the matching problem document; the
22
+ * `err.code` (e.g. `csv/invalid-record`) becomes the
23
+ * `type` URI suffix (`https://blamejs.com/problems/csv/
24
+ * invalid-record`).
25
+ *
26
+ * `b.problemDetails.validate(doc)` parses an INBOUND problem
27
+ * response (e.g. when blamejs is the client of an upstream API
28
+ * returning RFC 9457) — refuses non-objects, refuses bad type
29
+ * URIs, refuses status outside 100..599.
30
+ *
31
+ * Tier choices per `feedback_validation_tier_policy.md`:
32
+ * - create / fromError / validate — THROW on bad input
33
+ * (config-time / entry-point shape).
34
+ * - respond — THROW on bad input (its first call sets the
35
+ * response shape; silent drop would mask a programming bug).
36
+ *
37
+ * @card
38
+ * RFC 9457 Problem Details for HTTP APIs — standardized error response envelope with `application/problem+json` content type, supersedes RFC 7807.
39
+ */
40
+
41
+ var pick = require("./pick");
42
+ var validateOpts = require("./validate-opts");
43
+ var { defineClass } = require("./framework-error");
44
+
45
+ var POISONED_KEYS = pick.POISONED_KEYS;
46
+
47
+ var ProblemDetailsError = defineClass("ProblemDetailsError", { alwaysPermanent: true });
48
+
49
+ // Default problem-type URI base. Operators override via
50
+ // `b.problemDetails.setBase(url)` so deployments running under a
51
+ // custom domain emit problem URIs at their own host.
52
+ var STATE = {
53
+ baseUri: "https://blamejs.com/problems",
54
+ };
55
+
56
+ // RFC 9457 §3 reserved field names. Extensions land at the top level
57
+ // alongside these — RFC 9457 §3.2 explicitly endorses sibling
58
+ // extensions ("members SHOULD have unique names within their parent
59
+ // object"). Reserved names are NOT allowed as extension keys —
60
+ // re-using them silently masks a programming bug.
61
+ var RESERVED_FIELDS = Object.freeze([
62
+ "type", "title", "status", "detail", "instance",
63
+ ]);
64
+
65
+ /**
66
+ * @primitive b.problemDetails.setBase
67
+ * @signature b.problemDetails.setBase(baseUri)
68
+ * @since 0.8.84
69
+ * @status stable
70
+ * @related b.problemDetails.create, b.problemDetails.fromError
71
+ *
72
+ * Override the base URI prepended to error-code-derived `type` URIs.
73
+ * Default is `https://blamejs.com/problems`. Operators running under
74
+ * their own published vocabulary (e.g. an internal status page or
75
+ * an organization-owned problem catalog) point this at the canonical
76
+ * location. Throws `problem-details/bad-base` for non-https / non-
77
+ * absolute / non-string inputs.
78
+ *
79
+ * @example
80
+ * b.problemDetails.setBase("https://api.example.com/problems");
81
+ * b.problemDetails.fromError(new Error("csv/invalid-record")).type;
82
+ * // → "https://api.example.com/problems/csv/invalid-record"
83
+ */
84
+ function setBase(baseUri) {
85
+ validateOpts.requireNonEmptyString(
86
+ baseUri, "setBase.baseUri", ProblemDetailsError, "problem-details/bad-base");
87
+ if (!/^https?:\/\//.test(baseUri)) {
88
+ throw new ProblemDetailsError("problem-details/bad-base",
89
+ "setBase: baseUri must be an absolute http(s) URL", true);
90
+ }
91
+ // Strip trailing slash so the create()-path can prefix with "/".
92
+ // Manual loop instead of /\/+$/ regex — CodeQL flags the latter as
93
+ // a polynomial-time ReDoS candidate on operator-supplied input even
94
+ // though `/+` against an anchored tail is genuinely O(n); manual
95
+ // strip moots the warning + keeps O(n) explicit.
96
+ var bu = baseUri;
97
+ while (bu.length > 0 && bu.charAt(bu.length - 1) === "/") {
98
+ bu = bu.slice(0, -1);
99
+ }
100
+ STATE.baseUri = bu;
101
+ }
102
+
103
+ /**
104
+ * @primitive b.problemDetails.getBase
105
+ * @signature b.problemDetails.getBase()
106
+ * @since 0.8.84
107
+ * @status stable
108
+ * @related b.problemDetails.setBase
109
+ *
110
+ * Read the currently-configured base URI. Useful for diagnostic
111
+ * logging and tests.
112
+ *
113
+ * @example
114
+ * b.problemDetails.getBase(); // → "https://blamejs.com/problems"
115
+ */
116
+ function getBase() {
117
+ return STATE.baseUri;
118
+ }
119
+
120
+ /**
121
+ * @primitive b.problemDetails.create
122
+ * @signature b.problemDetails.create(opts)
123
+ * @since 0.8.84
124
+ * @status stable
125
+ * @related b.problemDetails.respond, b.problemDetails.fromError, b.problemDetails.validate
126
+ *
127
+ * Build a frozen RFC 9457 problem-details object. Validates the
128
+ * standard fields per §3:
129
+ * - `type` (optional, defaults to `"about:blank"`) must be a URI
130
+ * reference (string); MAY be relative or absolute.
131
+ * - `title` (recommended) must be a non-empty string when given.
132
+ * - `status` (recommended) must be an integer 100..599.
133
+ * - `detail` (optional) must be a string when given.
134
+ * - `instance` (optional) must be a URI reference string when given.
135
+ * - Extensions: every additional key whose name is NOT in
136
+ * `RESERVED_FIELDS` is preserved at the top level. Reserved-name
137
+ * collisions throw `problem-details/reserved-extension`.
138
+ *
139
+ * Returns a frozen plain object suitable for `JSON.stringify`.
140
+ *
141
+ * @opts
142
+ * type: string, // problem-type URI reference (default "about:blank")
143
+ * title: string, // short summary
144
+ * status: number, // integer 100..599
145
+ * detail: string, // human-readable explanation
146
+ * instance: string, // URI reference for this specific occurrence
147
+ * ...extensions // additional fields preserved as-is
148
+ *
149
+ * @example
150
+ * var p = b.problemDetails.create({
151
+ * type: "https://example.com/problems/out-of-credit",
152
+ * title: "You do not have enough credit.",
153
+ * status: 403,
154
+ * detail: "Your current balance is 30, but that costs 50.",
155
+ * instance: "/account/12345/msgs/abc",
156
+ * balance: 30,
157
+ * accounts: ["/account/12345", "/account/67890"],
158
+ * });
159
+ * // → {
160
+ * // type: "https://example.com/problems/out-of-credit",
161
+ * // title: "You do not have enough credit.",
162
+ * // status: 403,
163
+ * // detail: "Your current balance is 30, but that costs 50.",
164
+ * // instance: "/account/12345/msgs/abc",
165
+ * // balance: 30,
166
+ * // accounts: ["/account/12345", "/account/67890"]
167
+ * // }
168
+ */
169
+ function create(opts) {
170
+ if (!opts || typeof opts !== "object" || Array.isArray(opts)) {
171
+ throw new ProblemDetailsError("problem-details/bad-opts",
172
+ "create: opts must be a non-null object", true);
173
+ }
174
+ var out = {};
175
+
176
+ // type (RFC 9457 §3.1.1 — default "about:blank")
177
+ var typeIn = validateOpts.optionalNonEmptyString(
178
+ opts.type, "create.type", ProblemDetailsError, "problem-details/bad-type");
179
+ out.type = (typeIn === undefined || typeIn === null) ? "about:blank" : typeIn;
180
+
181
+ // title (§3.1.2 — short, human-readable summary)
182
+ var titleIn = validateOpts.optionalNonEmptyString(
183
+ opts.title, "create.title", ProblemDetailsError, "problem-details/bad-title");
184
+ if (titleIn !== undefined && titleIn !== null) {
185
+ out.title = titleIn;
186
+ }
187
+
188
+ // status (§3.1.3 — integer 100..599)
189
+ if (opts.status !== undefined) {
190
+ if (typeof opts.status !== "number" || !Number.isInteger(opts.status) ||
191
+ opts.status < 100 || opts.status > 599) { // allow:raw-byte-literal — HTTP status range bounds
192
+ throw new ProblemDetailsError("problem-details/bad-status",
193
+ "create: status must be an integer 100..599 when provided", true);
194
+ }
195
+ out.status = opts.status;
196
+ }
197
+
198
+ // detail (§3.1.4)
199
+ if (opts.detail !== undefined) {
200
+ if (typeof opts.detail !== "string") {
201
+ throw new ProblemDetailsError("problem-details/bad-detail",
202
+ "create: detail must be a string when provided", true);
203
+ }
204
+ out.detail = opts.detail;
205
+ }
206
+
207
+ // instance (§3.1.5 — URI reference)
208
+ var instanceIn = validateOpts.optionalNonEmptyString(
209
+ opts.instance, "create.instance", ProblemDetailsError, "problem-details/bad-instance");
210
+ if (instanceIn !== undefined && instanceIn !== null) {
211
+ out.instance = instanceIn;
212
+ }
213
+
214
+ // Extensions — every additional key. §3.2 endorses sibling
215
+ // extensions as long as their names don't collide with reserved.
216
+ var keys = Object.keys(opts);
217
+ for (var i = 0; i < keys.length; i += 1) {
218
+ var k = keys[i];
219
+ if (RESERVED_FIELDS.indexOf(k) !== -1) continue;
220
+ if (POISONED_KEYS.indexOf(k) !== -1) {
221
+ throw new ProblemDetailsError("problem-details/reserved-extension",
222
+ "create: extension key '" + k + "' refused (prototype-pollution shape)", true);
223
+ }
224
+ out[k] = opts[k];
225
+ }
226
+
227
+ return Object.freeze(out);
228
+ }
229
+
230
+ /**
231
+ * @primitive b.problemDetails.fromError
232
+ * @signature b.problemDetails.fromError(err, opts?)
233
+ * @since 0.8.84
234
+ * @status stable
235
+ * @related b.problemDetails.create, b.problemDetails.respond
236
+ *
237
+ * Convert a thrown `FrameworkError` (or any error with a `code` field)
238
+ * into the matching problem-details object. The error's `code`
239
+ * (e.g. `csv/invalid-record`) becomes the type-URI suffix
240
+ * (`<baseUri>/csv/invalid-record`); the error's `message` becomes
241
+ * `detail`; the error's `statusCode` becomes `status` when present,
242
+ * otherwise defaults to 500. Pass `opts.title` to override the title
243
+ * default (which is the error class name humanized: `CsvError` →
244
+ * "CSV Error"). Pass `opts.instance` to attach a request-instance
245
+ * reference (typically the audit-trail ID).
246
+ *
247
+ * @opts
248
+ * title: string, // override the derived title
249
+ * instance: string, // request-instance URI reference
250
+ * status: number, // override err.statusCode / default 500
251
+ *
252
+ * @example
253
+ * try {
254
+ * b.csv.parse(badInput);
255
+ * } catch (err) {
256
+ * var problem = b.problemDetails.fromError(err, {
257
+ * instance: "/audit/" + req.auditId,
258
+ * });
259
+ * b.problemDetails.respond(res, problem);
260
+ * }
261
+ */
262
+ function fromError(err, opts2) {
263
+ if (!err || typeof err !== "object") {
264
+ throw new ProblemDetailsError("problem-details/bad-error",
265
+ "fromError: err must be a non-null object", true);
266
+ }
267
+ opts2 = opts2 || {};
268
+ if (typeof opts2 !== "object" || Array.isArray(opts2)) {
269
+ throw new ProblemDetailsError("problem-details/bad-opts",
270
+ "fromError: opts must be an object when provided", true);
271
+ }
272
+ var code = typeof err.code === "string" && err.code.length > 0 ? err.code : "internal-error";
273
+ // Sanitize the code into a URI-safe path segment. RFC 3986
274
+ // unreserved chars + `/` for the namespace separator.
275
+ var typeUri = STATE.baseUri + "/" + code.replace(/[^A-Za-z0-9\-._/]/g, "-");
276
+
277
+ // Derived title: error.name humanized (CsvError → "Csv Error"),
278
+ // or operator override.
279
+ var title;
280
+ if (typeof opts2.title === "string" && opts2.title.length > 0) {
281
+ title = opts2.title;
282
+ } else if (typeof err.name === "string" && err.name.length > 0) {
283
+ title = err.name
284
+ .replace(/Error$/, " Error")
285
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
286
+ .trim();
287
+ } else {
288
+ title = "Error";
289
+ }
290
+
291
+ // Status: opt override > err.statusCode > 500.
292
+ var status;
293
+ if (opts2.status !== undefined) {
294
+ status = opts2.status;
295
+ } else if (typeof err.statusCode === "number" && Number.isInteger(err.statusCode) &&
296
+ err.statusCode >= 100 && err.statusCode <= 599) { // allow:raw-byte-literal — HTTP status range
297
+ status = err.statusCode;
298
+ } else {
299
+ status = 500; // allow:raw-byte-literal — default HTTP status 500 (Internal Server Error)
300
+ }
301
+
302
+ var built = {
303
+ type: typeUri,
304
+ title: title,
305
+ status: status,
306
+ detail: typeof err.message === "string" ? err.message : String(err),
307
+ };
308
+ if (typeof opts2.instance === "string" && opts2.instance.length > 0) {
309
+ built.instance = opts2.instance;
310
+ }
311
+ return create(built);
312
+ }
313
+
314
+ /**
315
+ * @primitive b.problemDetails.respond
316
+ * @signature b.problemDetails.respond(res, problem)
317
+ * @since 0.8.84
318
+ * @status stable
319
+ * @related b.problemDetails.create, b.problemDetails.fromError
320
+ *
321
+ * Write a problem-details object to the response with the correct
322
+ * RFC 9457 §3 content type (`application/problem+json`). Sets
323
+ * `Cache-Control: no-store` (RFC 9111 §5.2.2.5 — error responses
324
+ * are individualized) and writes the JSON body. Status code is
325
+ * taken from `problem.status` (or 500 when missing). Throws
326
+ * `problem-details/bad-res` for non-response objects; throws
327
+ * `problem-details/bad-problem` for non-object problem inputs.
328
+ *
329
+ * @example
330
+ * var problem = b.problemDetails.create({
331
+ * type: "https://blamejs.com/problems/csv/invalid-record",
332
+ * title: "CSV record validation failed",
333
+ * status: 400,
334
+ * detail: "Row 3 column 5 has an unterminated quoted field",
335
+ * });
336
+ * b.problemDetails.respond(res, problem);
337
+ * // res.headers: Content-Type: application/problem+json
338
+ * // Cache-Control: no-store
339
+ * // res.body: <JSON-stringified problem>
340
+ * // res.statusCode: 400
341
+ */
342
+ function respond(res, problem) {
343
+ if (!res || typeof res !== "object" || typeof res.setHeader !== "function" ||
344
+ typeof res.end !== "function") {
345
+ throw new ProblemDetailsError("problem-details/bad-res",
346
+ "respond: res must be an HTTP response object (setHeader + end)", true);
347
+ }
348
+ if (!problem || typeof problem !== "object" || Array.isArray(problem)) {
349
+ throw new ProblemDetailsError("problem-details/bad-problem",
350
+ "respond: problem must be a non-null object", true);
351
+ }
352
+ var status = (typeof problem.status === "number" && Number.isInteger(problem.status) &&
353
+ problem.status >= 100 && problem.status <= 599) ? problem.status : 500; // allow:raw-byte-literal — HTTP status range + default 500
354
+ var body = JSON.stringify(problem);
355
+ res.statusCode = status;
356
+ res.setHeader("Content-Type", "application/problem+json");
357
+ res.setHeader("Cache-Control", "no-store");
358
+ res.end(body);
359
+ }
360
+
361
+ /**
362
+ * @primitive b.problemDetails.validate
363
+ * @signature b.problemDetails.validate(doc)
364
+ * @since 0.8.84
365
+ * @status stable
366
+ * @related b.problemDetails.create
367
+ *
368
+ * Validate an INBOUND problem-details document (e.g. one received
369
+ * from an upstream API). Returns the doc unchanged on success;
370
+ * throws `problem-details/bad-inbound` on shape violations. Useful
371
+ * when blamejs is the client of a RFC 9457-compliant upstream
372
+ * service — converts a "looks JSON-ish" response into a verified
373
+ * problem object before reading fields.
374
+ *
375
+ * - Refuses non-object input.
376
+ * - Refuses `status` outside 100..599 or non-integer.
377
+ * - Refuses `type` / `title` / `detail` / `instance` of non-string
378
+ * shape when present.
379
+ * - Refuses prototype-pollution-shaped extension keys.
380
+ *
381
+ * @example
382
+ * var rsp = await fetch(url);
383
+ * if (rsp.headers.get("content-type") === "application/problem+json") {
384
+ * var doc = b.problemDetails.validate(await rsp.json());
385
+ * console.log(doc.title, doc.status, doc.detail);
386
+ * }
387
+ */
388
+ function validate(doc) {
389
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
390
+ throw new ProblemDetailsError("problem-details/bad-inbound",
391
+ "validate: doc must be a non-null object", true);
392
+ }
393
+ validateOpts.optionalNonEmptyString(
394
+ doc.type, "validate.type", ProblemDetailsError, "problem-details/bad-inbound");
395
+ if (doc.title !== undefined && typeof doc.title !== "string") {
396
+ throw new ProblemDetailsError("problem-details/bad-inbound",
397
+ "validate: title must be a string when present", true);
398
+ }
399
+ if (doc.status !== undefined) {
400
+ if (typeof doc.status !== "number" || !Number.isInteger(doc.status) ||
401
+ doc.status < 100 || doc.status > 599) { // allow:raw-byte-literal — HTTP status range
402
+ throw new ProblemDetailsError("problem-details/bad-inbound",
403
+ "validate: status must be an integer 100..599 when present", true);
404
+ }
405
+ }
406
+ if (doc.detail !== undefined && typeof doc.detail !== "string") {
407
+ throw new ProblemDetailsError("problem-details/bad-inbound",
408
+ "validate: detail must be a string when present", true);
409
+ }
410
+ validateOpts.optionalNonEmptyString(
411
+ doc.instance, "validate.instance", ProblemDetailsError, "problem-details/bad-inbound");
412
+ // Refuse prototype-pollution shape in keys.
413
+ var keys = Object.keys(doc);
414
+ for (var i = 0; i < keys.length; i += 1) {
415
+ if (POISONED_KEYS.indexOf(keys[i]) !== -1) {
416
+ throw new ProblemDetailsError("problem-details/bad-inbound",
417
+ "validate: doc key '" + keys[i] + "' refused (prototype-pollution shape)", true);
418
+ }
419
+ }
420
+ return doc;
421
+ }
422
+
423
+ // Boundary helper for resetting test state between cases.
424
+ function _resetForTest() {
425
+ STATE.baseUri = "https://blamejs.com/problems";
426
+ }
427
+
428
+ module.exports = {
429
+ setBase: setBase,
430
+ getBase: getBase,
431
+ create: create,
432
+ fromError: fromError,
433
+ respond: respond,
434
+ validate: validate,
435
+ RESERVED_FIELDS: RESERVED_FIELDS,
436
+ ProblemDetailsError: ProblemDetailsError,
437
+ _resetForTest: _resetForTest,
438
+ };
439
+
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.serverTiming
4
+ * @nav HTTP
5
+ * @title Server-Timing
6
+ * @order 315
7
+ *
8
+ * @intro
9
+ * W3C Server-Timing response header builder. Lets the server
10
+ * describe per-request timing metrics (database query duration,
11
+ * downstream HTTP call latency, encryption time) so the browser's
12
+ * Performance API exposes them to client-side telemetry.
13
+ *
14
+ * The header is a comma-separated list of `name; dur=<ms>; desc=<text>`
15
+ * entries. Builder primitives are immutable per-request collectors
16
+ * that operators populate over the lifetime of the request and
17
+ * serialize at response-write time.
18
+ *
19
+ * `b.serverTiming.create()` returns a per-request collector:
20
+ *
21
+ * var timing = b.serverTiming.create();
22
+ * timing.mark("db.query", 12.5, "user fetch");
23
+ * timing.mark("encrypt", 3.1);
24
+ * res.setHeader("Server-Timing", timing.toHeader());
25
+ * // → "db.query; dur=12.5; desc=\"user fetch\", encrypt; dur=3.1"
26
+ *
27
+ * Use `timing.measure(name, fn)` to time an async operation
28
+ * inline:
29
+ *
30
+ * var rows = await timing.measure("db.query", function () {
31
+ * return db.query("SELECT ...");
32
+ * });
33
+ *
34
+ * @card
35
+ * W3C Server-Timing response header builder — per-request timing-metric collector that surfaces server-side latency in the browser's Performance API.
36
+ */
37
+
38
+ var validateOpts = require("./validate-opts");
39
+ var { defineClass } = require("./framework-error");
40
+
41
+ var ServerTimingError = defineClass("ServerTimingError", { alwaysPermanent: true });
42
+
43
+ // W3C Server-Timing §3 — metric-name is token shape (RFC 7230). Cap
44
+ // at 128 chars for sanity; operator-supplied desc is sf-string.
45
+ var METRIC_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,128}$/; // allow:raw-byte-literal — RFC 7230 token shape + length cap
46
+
47
+ function _quoteDesc(s) {
48
+ return "\"" + String(s).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + "\"";
49
+ }
50
+
51
+ /**
52
+ * @primitive b.serverTiming.create
53
+ * @signature b.serverTiming.create()
54
+ * @since 0.8.86
55
+ * @status stable
56
+ * @related b.serverTiming.entry
57
+ *
58
+ * Return a per-request collector with `mark` / `measure` / `toHeader`
59
+ * methods. The collector is mutable + scoped to a single request;
60
+ * operators discard or stringify at response-write time. Throws
61
+ * `server-timing/bad-name` for non-token metric names and
62
+ * `server-timing/bad-duration` for non-finite negative duration.
63
+ *
64
+ * @example
65
+ * var timing = b.serverTiming.create();
66
+ * timing.mark("cache.lookup", 0.3);
67
+ * var data = await timing.measure("db.query", function () { return db.query("..."); });
68
+ * res.setHeader("Server-Timing", timing.toHeader());
69
+ */
70
+ function create() {
71
+ var entries = [];
72
+
73
+ function mark(name, durationMs, description) {
74
+ validateOpts.requireNonEmptyString(
75
+ name, "serverTiming.mark.name", ServerTimingError, "server-timing/bad-name");
76
+ if (name.length > 128 || !METRIC_NAME_RE.test(name)) { // allow:raw-byte-literal — metric-name length cap, not bytes
77
+ throw new ServerTimingError("server-timing/bad-name",
78
+ "metric name '" + name + "' must match RFC 7230 token + <= 128 chars");
79
+ }
80
+ if (durationMs !== undefined && durationMs !== null) {
81
+ if (typeof durationMs !== "number" || !isFinite(durationMs) || durationMs < 0) {
82
+ throw new ServerTimingError("server-timing/bad-duration",
83
+ "duration must be a non-negative finite number when provided");
84
+ }
85
+ }
86
+ if (description !== undefined && description !== null) {
87
+ if (typeof description !== "string") {
88
+ throw new ServerTimingError("server-timing/bad-description",
89
+ "description must be a string when provided");
90
+ }
91
+ }
92
+ entries.push({
93
+ name: name,
94
+ dur: typeof durationMs === "number" ? durationMs : null,
95
+ desc: typeof description === "string" ? description : null,
96
+ });
97
+ return entries[entries.length - 1];
98
+ }
99
+
100
+ async function measure(name, fn) {
101
+ if (typeof fn !== "function") {
102
+ throw new ServerTimingError("server-timing/bad-fn",
103
+ "measure: fn must be a function", true);
104
+ }
105
+ var start = _now();
106
+ try {
107
+ var result = await fn();
108
+ mark(name, _now() - start);
109
+ return result;
110
+ } catch (err) {
111
+ mark(name, _now() - start, "error");
112
+ throw err;
113
+ }
114
+ }
115
+
116
+ function toHeader() {
117
+ if (entries.length === 0) return "";
118
+ return entries.map(function (e) {
119
+ var parts = [e.name];
120
+ if (e.dur !== null) parts.push("dur=" + _formatDur(e.dur));
121
+ if (e.desc !== null) parts.push("desc=" + _quoteDesc(e.desc));
122
+ return parts.join("; ");
123
+ }).join(", ");
124
+ }
125
+
126
+ function snapshot() {
127
+ return entries.map(function (e) { return Object.assign({}, e); });
128
+ }
129
+
130
+ return { mark: mark, measure: measure, toHeader: toHeader, snapshot: snapshot };
131
+ }
132
+
133
+ /**
134
+ * @primitive b.serverTiming.entry
135
+ * @signature b.serverTiming.entry(name, durationMs?, description?)
136
+ * @since 0.8.86
137
+ * @status stable
138
+ * @related b.serverTiming.create
139
+ *
140
+ * Format a single Server-Timing entry without building a collector.
141
+ * Useful when the operator wants a one-shot header value without
142
+ * threading a collector through the request scope.
143
+ *
144
+ * @example
145
+ * res.setHeader("Server-Timing", b.serverTiming.entry("db.query", 12.5));
146
+ * // → "db.query; dur=12.5"
147
+ */
148
+ function entryString(name, durationMs, description) {
149
+ var c = create();
150
+ c.mark(name, durationMs, description);
151
+ return c.toHeader();
152
+ }
153
+
154
+ function _now() {
155
+ // Prefer process.hrtime when available (sub-ms precision); fall back
156
+ // to Date.now in environments without it.
157
+ if (typeof process !== "undefined" && typeof process.hrtime === "function" &&
158
+ typeof process.hrtime.bigint === "function") {
159
+ return Number(process.hrtime.bigint() / 1000n) / 1000; // allow:raw-byte-literal — hrtime ns→ms scale, not bytes
160
+ }
161
+ return Date.now();
162
+ }
163
+
164
+ function _formatDur(ms) {
165
+ // W3C spec says dur is a number; emit at most 3 decimal places.
166
+ if (Number.isInteger(ms)) return String(ms);
167
+ return ms.toFixed(3).replace(/\.?0+$/, "");
168
+ }
169
+
170
+ module.exports = {
171
+ create: create,
172
+ entry: entryString,
173
+ ServerTimingError: ServerTimingError,
174
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.82",
3
+ "version": "0.8.86",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:309a8ed5-6be3-41c5-b29a-f23cdc9a41ca",
5
+ "serialNumber": "urn:uuid:94157e54-2402-4932-84de-96074af286be",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-11T15:08:03.856Z",
8
+ "timestamp": "2026-05-11T17:16:07.899Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.8.82",
22
+ "bom-ref": "@blamejs/core@0.8.86",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.82",
25
+ "version": "0.8.86",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.8.82",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.86",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.8.82",
57
+ "ref": "@blamejs/core@0.8.86",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]