@blamejs/core 0.10.12 → 0.10.14
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/README.md +2 -0
- package/index.js +4 -0
- package/lib/cms-codec.js +685 -0
- package/lib/daemon.js +29 -4
- package/lib/mail-crypto-pgp.js +10 -9
- package/lib/mail-crypto-smime.js +15 -31
- package/lib/metrics.js +68 -8
- package/lib/stream-throttle.js +235 -0
- package/lib/subject.js +14 -10
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/daemon.js
CHANGED
|
@@ -237,13 +237,37 @@ function start(opts) {
|
|
|
237
237
|
throw new DaemonError("daemon/already-running",
|
|
238
238
|
"daemon.start: pidFile '" + pidFile + "' held by live PID " + existingLive);
|
|
239
239
|
}
|
|
240
|
-
|
|
240
|
+
// Detached-stdio strategy diverges by platform:
|
|
241
|
+
//
|
|
242
|
+
// POSIX: inherit the parent's open log FD via stdio so the child
|
|
243
|
+
// writes to the operator's log file without re-opening it. POSIX
|
|
244
|
+
// keeps the FD alive across the parent's exit; the child sees it
|
|
245
|
+
// as fd 1 / 2 and writes normally.
|
|
246
|
+
//
|
|
247
|
+
// Windows: passing a parent-opened FD through stdio causes the
|
|
248
|
+
// child to die the moment the parent's handle is closed (the OS
|
|
249
|
+
// ref-counts file handles per-process and the inherited handle
|
|
250
|
+
// becomes invalid on parent exit). The Windows-safe pattern is
|
|
251
|
+
// `stdio: "ignore"` + `windowsHide: true` so the child has no
|
|
252
|
+
// inherited handles to lose, and the operator's child code opens
|
|
253
|
+
// the log file itself once its logger initialises. The child is
|
|
254
|
+
// responsible for `--log` parsing on Windows — pass it via
|
|
255
|
+
// `opts.args` and let the application code handle the open.
|
|
256
|
+
var isWindows = process.platform === "win32";
|
|
257
|
+
var logFd = (!isWindows && logFile) ? _openLogFd(logFile) : null;
|
|
258
|
+
var spawnStdio;
|
|
259
|
+
if (isWindows || logFd === null) {
|
|
260
|
+
spawnStdio = "ignore";
|
|
261
|
+
} else {
|
|
262
|
+
spawnStdio = ["ignore", logFd, logFd];
|
|
263
|
+
}
|
|
241
264
|
var child;
|
|
242
265
|
try {
|
|
243
266
|
child = processSpawn.spawn(opts.command, opts.args || [], {
|
|
244
|
-
detached:
|
|
245
|
-
stdio:
|
|
246
|
-
cwd:
|
|
267
|
+
detached: true,
|
|
268
|
+
stdio: spawnStdio,
|
|
269
|
+
cwd: typeof opts.cwd === "string" ? opts.cwd : undefined,
|
|
270
|
+
windowsHide: isWindows ? true : undefined,
|
|
247
271
|
});
|
|
248
272
|
} catch (e) {
|
|
249
273
|
try { if (typeof logFd === "number") nodeFs.closeSync(logFd); }
|
|
@@ -267,6 +291,7 @@ function start(opts) {
|
|
|
267
291
|
logFile: logFile,
|
|
268
292
|
commandKind: "detached-fork",
|
|
269
293
|
pid: child.pid,
|
|
294
|
+
stdioMode: isWindows ? "ignore-windows" : (logFd === null ? "ignore" : "inherit-logfd"),
|
|
270
295
|
});
|
|
271
296
|
log("daemon started (detached) pid=" + child.pid + " pidFile=" + pidFile);
|
|
272
297
|
return { pid: child.pid, pidFile: pidFile, logFile: logFile, mode: "detached" };
|
package/lib/mail-crypto-pgp.js
CHANGED
|
@@ -60,15 +60,16 @@
|
|
|
60
60
|
* Deferred from v1 (each with the documented condition for opting in):
|
|
61
61
|
* - In-process encrypt + decrypt (Message Encrypted Session Key +
|
|
62
62
|
* Symmetrically Encrypted Integrity Protected Data packets,
|
|
63
|
-
* RFC 9580 §5.1 / §5.13)
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* `b.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
63
|
+
* RFC 9580 §5.1 / §5.13) and WKD key discovery (draft-koch-
|
|
64
|
+
* openpgp-webkey-service). Defer condition: ships in v0.10.14
|
|
65
|
+
* alongside `b.mail.crypto.smime` sign + verify — the CMS
|
|
66
|
+
* substrate `b.cms` landed in v0.10.13 unblocked the S/MIME
|
|
67
|
+
* side, and OpenPGP encrypt rides the same release so the
|
|
68
|
+
* mail-crypto surface lights up coherently rather than half-
|
|
69
|
+
* on-each-side across two patches. Cheap escape hatch (pre-
|
|
70
|
+
* v0.10.14): operators wire a third-party OpenPGP library in
|
|
71
|
+
* their own consumer code and call this module's sign() /
|
|
72
|
+
* verify() on the resulting cleartext blob.
|
|
72
73
|
* - v6 signature packets (RFC 9580 §5.2.3, packet version 6 with
|
|
73
74
|
* SHA2-512 fingerprints and salted hashes). Defer condition: v6
|
|
74
75
|
* is not yet emitted by GnuPG 2.4 LTS or by Sequoia stable, so
|
package/lib/mail-crypto-smime.js
CHANGED
|
@@ -64,38 +64,22 @@
|
|
|
64
64
|
* packet decoder shipped in `b.mail.crypto.pgp` — but with
|
|
65
65
|
* dramatically more shape variation across implementations.
|
|
66
66
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* in-process S/MIME verify (use case + sample message
|
|
76
|
-
* shape).
|
|
77
|
-
* 2. A vendorable ASN.1 BER/DER decoder lands in `lib/vendor/`
|
|
78
|
-
* under the framework's vendoring discipline (MANIFEST.json
|
|
79
|
-
* + sha256 pin + no transitive deps), OR an operator
|
|
80
|
-
* provides a tested decoder we can fold in directly.
|
|
81
|
-
* 3. RFC 8551 §2.5 + RFC 5652 §11 conformance test vectors are
|
|
82
|
-
* available to drive the implementation. (NIST PKITS-style
|
|
83
|
-
* test vectors exist for X.509 chain validation; equivalent
|
|
84
|
-
* coverage for CMS SignedData is sparser.)
|
|
67
|
+
* Reopen condition: the in-tree CMS substrate (`b.cms`) shipped
|
|
68
|
+
* in v0.10.13 — the RFC 5652 SignedData encode + decode + PQC
|
|
69
|
+
* signer dispatch is now available. The S/MIME wire layer
|
|
70
|
+
* (multipart/signed framing, micalg mapping, base64 DER body,
|
|
71
|
+
* Content-Type parameters) lights up on top of `b.cms` in
|
|
72
|
+
* v0.10.14 alongside `b.mail.crypto.pgp` encrypt + decrypt + WKD
|
|
73
|
+
* discovery, so operators get the full mail-crypto surface in a
|
|
74
|
+
* single release rather than half of each side.
|
|
85
75
|
*
|
|
86
|
-
* Cheap escape hatch: operators
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* any inbound S/MIME-signed message regardless of this module's
|
|
94
|
-
* state.
|
|
95
|
-
*
|
|
96
|
-
* v2 reopen tag: the next minor (v0.9.60+) once the conditions
|
|
97
|
-
* above are met. The deferred surface lights up sign+verify
|
|
98
|
-
* together so operators never see a half-implementation.
|
|
76
|
+
* Cheap escape hatch (pre-v0.10.14): operators wanting in-process
|
|
77
|
+
* S/MIME today compose `b.cms.encodeSignedData` directly with a
|
|
78
|
+
* hand-written multipart/signed wrapper. The MIME framing is two
|
|
79
|
+
* parts (the signed content + `application/pkcs7-signature` body
|
|
80
|
+
* carrying the base64-encoded CMS DER from `b.cms`); the helper
|
|
81
|
+
* in v0.10.14 collapses that into `b.mail.crypto.smime.sign({ ... })`
|
|
82
|
+
* so the next-release path is additive, not a rewrite.
|
|
99
83
|
*
|
|
100
84
|
* RFC citations:
|
|
101
85
|
* - RFC 8551 (S/MIME 4.0 Message Specification, April 2019;
|
package/lib/metrics.js
CHANGED
|
@@ -783,23 +783,66 @@ function _resetForTest() {
|
|
|
783
783
|
* path: string, // absolute path to write the snapshot
|
|
784
784
|
* intervalMs: number, // milliseconds between flushes (>=100)
|
|
785
785
|
* fields: Function, // returns an object — written as JSON
|
|
786
|
+
* registry: object, // optional `b.metrics.create()` handle — adds a
|
|
787
|
+
* // structured `metrics` field carrying every
|
|
788
|
+
* // registered counter / gauge / histogram (incl.
|
|
789
|
+
* // bucket counts) so sidecar readers compose
|
|
790
|
+
* // histogram_quantile() against the snapshot
|
|
786
791
|
* fileMode: number, // POSIX mode (default 0o640 — owner rw, group r)
|
|
787
792
|
*
|
|
788
793
|
* @example
|
|
794
|
+
* var registry = b.metrics.create();
|
|
795
|
+
* var latency = registry.histogram("op_latency_seconds", { buckets: [0.01, 0.1, 1] });
|
|
789
796
|
* var stop = b.metrics.snapshot.startWriter({
|
|
790
797
|
* path: "/run/blamejs/metrics.json",
|
|
791
798
|
* intervalMs: 5000,
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
* uptimeMs: process.uptime() * 1000,
|
|
795
|
-
* queueDepth: myQueue.size,
|
|
796
|
-
* lastSyncAt: lastSyncAt,
|
|
797
|
-
* };
|
|
798
|
-
* },
|
|
799
|
+
* registry: registry,
|
|
800
|
+
* fields: function () { return { uptimeMs: process.uptime() * 1000 }; },
|
|
799
801
|
* });
|
|
800
|
-
* //
|
|
802
|
+
* // Snapshot file: { writtenAt, fields, metrics: { op_latency_seconds: { type, buckets, observations: [{ labels, counts, sum, count }] } } }
|
|
801
803
|
* stop();
|
|
802
804
|
*/
|
|
805
|
+
function _serializeRegistry(registry) {
|
|
806
|
+
// Walk every registered metric in the registry.metrics Map and emit
|
|
807
|
+
// a JSON-friendly structured shape. Histograms get full buckets +
|
|
808
|
+
// bucket counts so downstream consumers compose
|
|
809
|
+
// `histogram_quantile()` against the snapshot without a separate
|
|
810
|
+
// exposition endpoint (issue #100).
|
|
811
|
+
var out = {};
|
|
812
|
+
var names = registry.metrics instanceof Map
|
|
813
|
+
? Array.from(registry.metrics.keys()).sort()
|
|
814
|
+
: Object.keys(registry.metrics).sort();
|
|
815
|
+
for (var i = 0; i < names.length; i += 1) {
|
|
816
|
+
var name = names[i];
|
|
817
|
+
var m = registry.metrics instanceof Map ? registry.metrics.get(name) : registry.metrics[name];
|
|
818
|
+
if (!m) continue;
|
|
819
|
+
var entry = { type: m.type, help: m.help || "", labelNames: m.labelNames || [] };
|
|
820
|
+
if (m.type === "histogram") {
|
|
821
|
+
entry.buckets = m.buckets.slice();
|
|
822
|
+
entry.observations = [];
|
|
823
|
+
var hKeys = m.values instanceof Map ? Array.from(m.values.keys()).sort() : Object.keys(m.values).sort();
|
|
824
|
+
for (var hi = 0; hi < hKeys.length; hi += 1) {
|
|
825
|
+
var hv = m.values instanceof Map ? m.values.get(hKeys[hi]) : m.values[hKeys[hi]];
|
|
826
|
+
entry.observations.push({
|
|
827
|
+
labels: hv.labels,
|
|
828
|
+
counts: hv.counts.slice(),
|
|
829
|
+
sum: hv.sum,
|
|
830
|
+
count: hv.count,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
} else {
|
|
834
|
+
entry.observations = [];
|
|
835
|
+
var vKeys = m.values instanceof Map ? Array.from(m.values.keys()).sort() : Object.keys(m.values).sort();
|
|
836
|
+
for (var vi = 0; vi < vKeys.length; vi += 1) {
|
|
837
|
+
var vv = m.values instanceof Map ? m.values.get(vKeys[vi]) : m.values[vKeys[vi]];
|
|
838
|
+
entry.observations.push({ labels: vv.labels, value: vv.value });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
out[name] = entry;
|
|
842
|
+
}
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
|
|
803
846
|
function snapshotStartWriter(opts) {
|
|
804
847
|
opts = opts || {};
|
|
805
848
|
validateOpts.requireNonEmptyString(opts.path,
|
|
@@ -813,8 +856,21 @@ function snapshotStartWriter(opts) {
|
|
|
813
856
|
throw new MetricsError("metrics-snapshot/bad-fields",
|
|
814
857
|
"metrics.snapshot.startWriter: opts.fields must be a function returning the snapshot object");
|
|
815
858
|
}
|
|
859
|
+
// Issue #100 — optional `registry` handle pulls every registered
|
|
860
|
+
// metric into a structured `metrics` field in the JSON snapshot:
|
|
861
|
+
// counters / gauges as `{ value }` per label set, histograms as
|
|
862
|
+
// `{ buckets, observations }` with bucket counts + sum + count.
|
|
863
|
+
// Sidecar readers compose `histogram_quantile()` against the
|
|
864
|
+
// snapshot file without running a separate /metrics endpoint.
|
|
865
|
+
if (opts.registry !== undefined && opts.registry !== null &&
|
|
866
|
+
(typeof opts.registry !== "object" || typeof opts.registry.metrics !== "object")) {
|
|
867
|
+
throw new MetricsError("metrics-snapshot/bad-registry",
|
|
868
|
+
"metrics.snapshot.startWriter: opts.registry must be a metrics registry " +
|
|
869
|
+
"(from b.metrics.create()) or omitted");
|
|
870
|
+
}
|
|
816
871
|
var p = opts.path;
|
|
817
872
|
var fieldsFn = opts.fields;
|
|
873
|
+
var registry = opts.registry || null;
|
|
818
874
|
var intervalMs = opts.intervalMs;
|
|
819
875
|
// CRYPTO-6 — file mode for the atomic write. Default 0o640
|
|
820
876
|
// (owner rw, group r, world none). Operators with a sidecar
|
|
@@ -844,6 +900,10 @@ function snapshotStartWriter(opts) {
|
|
|
844
900
|
writtenAt: new Date().toISOString(),
|
|
845
901
|
fields: snap,
|
|
846
902
|
};
|
|
903
|
+
if (registry) {
|
|
904
|
+
try { payload.metrics = _serializeRegistry(registry); }
|
|
905
|
+
catch (e2) { log("snapshot.metrics serialize failed: " + ((e2 && e2.message) || String(e2))); }
|
|
906
|
+
}
|
|
847
907
|
try {
|
|
848
908
|
// CRYPTO-6 — default 0o640 (owner rw, group r, world none) so
|
|
849
909
|
// operator-supplied snapshot fields aren't world-readable on a
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.streamThrottle
|
|
4
|
+
* @nav Networking
|
|
5
|
+
* @title Stream Throttle
|
|
6
|
+
* @order 130
|
|
7
|
+
* @slug stream-throttle
|
|
8
|
+
*
|
|
9
|
+
* @card
|
|
10
|
+
* Shared token-bucket bandwidth limiter for `node:stream` pipelines.
|
|
11
|
+
* Caps aggregate bytes-per-second across N concurrent streams that
|
|
12
|
+
* draw from the same bucket — the missing primitive between per-
|
|
13
|
+
* request rate-limit and per-process worker pool.
|
|
14
|
+
*
|
|
15
|
+
* @intro
|
|
16
|
+
* `b.streamThrottle.create({ bytesPerSec, burstBytes })` returns a
|
|
17
|
+
* token bucket that hands out `transform()` instances; every
|
|
18
|
+
* transform consumes from the same shared bucket. Operators wiring
|
|
19
|
+
* bulk-transfer daemons (object-storage fan-out, log shippers,
|
|
20
|
+
* replication readers) compose a single throttle and apply it
|
|
21
|
+
* to every concurrent transfer — N parallel transforms share the
|
|
22
|
+
* `bytesPerSec` budget rather than each getting their own.
|
|
23
|
+
*
|
|
24
|
+
* Algorithm:
|
|
25
|
+
*
|
|
26
|
+
* - Bucket holds up to `burstBytes` tokens (default = `bytesPerSec`,
|
|
27
|
+
* i.e. one second of headroom). Tokens refill at `bytesPerSec`
|
|
28
|
+
* bytes per second, capped at `burstBytes`. Refill is computed
|
|
29
|
+
* lazily on every chunk write so there is no per-throttle timer.
|
|
30
|
+
* - On each chunk, the transform asks the bucket for the chunk's
|
|
31
|
+
* byte count. If enough tokens are available, the chunk passes
|
|
32
|
+
* immediately and the tokens are decremented. If not, the
|
|
33
|
+
* transform sleeps for `ceil((bytes - tokens) / bytesPerSec * 1000)`
|
|
34
|
+
* ms and then retries — the chunk is forwarded as-is once the
|
|
35
|
+
* debt is paid.
|
|
36
|
+
*
|
|
37
|
+
* Composes with:
|
|
38
|
+
*
|
|
39
|
+
* - `node:stream.pipeline(src, throttle.transform(), dst)` — the
|
|
40
|
+
* transform is a regular `stream.Transform`, so backpressure
|
|
41
|
+
* flows in both directions without operator wiring.
|
|
42
|
+
* - `b.appShutdown` — the throttle has no background timer; once
|
|
43
|
+
* every transform finishes its `_transform`, the bucket is
|
|
44
|
+
* garbage-collected with the surrounding daemon.
|
|
45
|
+
*
|
|
46
|
+
* Refusal posture:
|
|
47
|
+
*
|
|
48
|
+
* - `bytesPerSec <= 0` / non-finite throws `stream-throttle/bad-rate`.
|
|
49
|
+
* - `burstBytes < bytesPerSec` throws `stream-throttle/bad-burst`
|
|
50
|
+
* (smaller burst than refill rate would stall on a single full-rate
|
|
51
|
+
* chunk forever).
|
|
52
|
+
* - Chunks larger than `burstBytes` would never fit in the bucket;
|
|
53
|
+
* `transform({ allowOversize: true })` opts into splitting them
|
|
54
|
+
* across multiple wait windows. Default refuses with a typed error
|
|
55
|
+
* so operators catch this at config time.
|
|
56
|
+
*
|
|
57
|
+
* RFC + reference:
|
|
58
|
+
*
|
|
59
|
+
* - [RFC 2697 srTCM](https://www.rfc-editor.org/rfc/rfc2697.html) — single-rate
|
|
60
|
+
* three-color marker, the canonical token-bucket shape this primitive
|
|
61
|
+
* implements (single PIR + CBS, no committed burst tier).
|
|
62
|
+
* - [Wikipedia: Token bucket](https://en.wikipedia.org/wiki/Token_bucket).
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
var nodeStream = require("node:stream");
|
|
66
|
+
var { defineClass } = require("./framework-error");
|
|
67
|
+
|
|
68
|
+
var StreamThrottleError = defineClass("StreamThrottleError", { alwaysPermanent: true });
|
|
69
|
+
|
|
70
|
+
// Milliseconds-per-second conversion factor — used for rate arithmetic
|
|
71
|
+
// (bytes/sec ↔ wait-ms). This is a unit-conversion constant, not a
|
|
72
|
+
// memory cap or protocol-byte literal; the framework's C.TIME / C.BYTES
|
|
73
|
+
// helpers don't apply.
|
|
74
|
+
var MS_PER_SECOND = 1000; // allow:raw-byte-literal — ms/sec unit conversion // allow:raw-time-literal — ms/sec unit conversion
|
|
75
|
+
var NS_PER_MS = 1e6; // allow:raw-byte-literal — ns/ms unit conversion
|
|
76
|
+
var MS_PER_SECOND_HRTIME = 1000; // allow:raw-byte-literal — hrtime seconds→ms // allow:raw-time-literal — hrtime seconds→ms
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @primitive b.streamThrottle.create
|
|
80
|
+
* @signature b.streamThrottle.create(opts)
|
|
81
|
+
* @since 0.10.13
|
|
82
|
+
* @status stable
|
|
83
|
+
* @related b.streamThrottle
|
|
84
|
+
*
|
|
85
|
+
* Create a shared token bucket. Returns `{ transform(opts?), state() }`.
|
|
86
|
+
* `transform(tOpts?)` returns a `stream.Transform` that consumes from
|
|
87
|
+
* the shared bucket; multiple transforms returned from the same
|
|
88
|
+
* bucket share the rate budget. `state()` returns
|
|
89
|
+
* `{ bytesPerSec, burstBytes, tokens, lastRefillMs }` for observation.
|
|
90
|
+
*
|
|
91
|
+
* Refill resilience: `_refill` clamps elapsed-since-last-refill to
|
|
92
|
+
* the "empty-to-full" duration (`burstBytes / bytesPerSec` seconds)
|
|
93
|
+
* so an NTP clock step or VM resume can't credit hours of pent-up
|
|
94
|
+
* tokens into the bucket in a single call.
|
|
95
|
+
*
|
|
96
|
+
* @opts
|
|
97
|
+
* bytesPerSec: number, // refill rate (bytes per second; required, > 0)
|
|
98
|
+
* burstBytes: number, // bucket capacity (default = bytesPerSec)
|
|
99
|
+
*
|
|
100
|
+
* `transform(tOpts)` opts:
|
|
101
|
+
* allowOversize: boolean, // permit chunks larger than burstBytes (default false)
|
|
102
|
+
* maxWaitMs: number, // per-chunk wait ceiling — when set, any
|
|
103
|
+
* // computed wait > maxWaitMs refuses the chunk
|
|
104
|
+
* // with `stream-throttle/wait-exceeds-max`
|
|
105
|
+
* // instead of silently pinning the pipeline.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* var throttle = b.streamThrottle.create({ bytesPerSec: 5 * 1024 * 1024 });
|
|
109
|
+
* await new Promise(function (resolve, reject) {
|
|
110
|
+
* require("node:stream").pipeline(src, throttle.transform(), dst,
|
|
111
|
+
* function (e) { return e ? reject(e) : resolve(); });
|
|
112
|
+
* });
|
|
113
|
+
*/
|
|
114
|
+
function create(opts) {
|
|
115
|
+
opts = opts || {};
|
|
116
|
+
if (typeof opts.bytesPerSec !== "number" || !isFinite(opts.bytesPerSec) || opts.bytesPerSec <= 0) {
|
|
117
|
+
throw new StreamThrottleError("stream-throttle/bad-rate",
|
|
118
|
+
"streamThrottle.create: opts.bytesPerSec must be a finite number > 0, got " + opts.bytesPerSec);
|
|
119
|
+
}
|
|
120
|
+
var bytesPerSec = opts.bytesPerSec;
|
|
121
|
+
var burstBytes = opts.burstBytes !== undefined ? opts.burstBytes : bytesPerSec;
|
|
122
|
+
if (typeof burstBytes !== "number" || !isFinite(burstBytes) || burstBytes <= 0) {
|
|
123
|
+
throw new StreamThrottleError("stream-throttle/bad-burst",
|
|
124
|
+
"streamThrottle.create: opts.burstBytes must be a finite number > 0, got " + burstBytes);
|
|
125
|
+
}
|
|
126
|
+
if (burstBytes < bytesPerSec) {
|
|
127
|
+
throw new StreamThrottleError("stream-throttle/bad-burst",
|
|
128
|
+
"streamThrottle.create: opts.burstBytes (" + burstBytes + ") must be >= bytesPerSec (" +
|
|
129
|
+
bytesPerSec + ") — a smaller burst than refill rate stalls forever on a single full-rate chunk");
|
|
130
|
+
}
|
|
131
|
+
var tokens = burstBytes;
|
|
132
|
+
var lastRefill = _hrtimeMs();
|
|
133
|
+
|
|
134
|
+
// Cap how far elapsed-since-last-refill can stretch in one call.
|
|
135
|
+
// Without the cap, a system clock jump (NTP step / VM resume / a
|
|
136
|
+
// process suspended in a debugger) credits the bucket with enough
|
|
137
|
+
// tokens to drain hours of pent-up backlog in a single chunk —
|
|
138
|
+
// defeating the rate ceiling for the recovery window. The cap
|
|
139
|
+
// is `burstBytes / bytesPerSec` seconds — exactly the time it
|
|
140
|
+
// takes to refill an empty bucket to full at the configured rate
|
|
141
|
+
// — so legitimate idle periods recover correctly while clock
|
|
142
|
+
// skew never overshoots.
|
|
143
|
+
var maxElapsedMs = Math.ceil((burstBytes / bytesPerSec) * MS_PER_SECOND);
|
|
144
|
+
|
|
145
|
+
function _refill() {
|
|
146
|
+
var now = _hrtimeMs();
|
|
147
|
+
var elapsed = now - lastRefill;
|
|
148
|
+
if (elapsed > maxElapsedMs) elapsed = maxElapsedMs;
|
|
149
|
+
if (elapsed > 0) {
|
|
150
|
+
tokens = Math.min(burstBytes, tokens + (elapsed / MS_PER_SECOND) * bytesPerSec);
|
|
151
|
+
lastRefill = now;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _consume(bytes, allowOversize) {
|
|
156
|
+
if (bytes > burstBytes && !allowOversize) {
|
|
157
|
+
throw new StreamThrottleError("stream-throttle/oversize-chunk",
|
|
158
|
+
"chunk of " + bytes + " bytes exceeds burstBytes=" + burstBytes +
|
|
159
|
+
"; pass transform({ allowOversize: true }) to split across wait windows");
|
|
160
|
+
}
|
|
161
|
+
_refill();
|
|
162
|
+
if (tokens >= bytes) {
|
|
163
|
+
tokens -= bytes;
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
// Bucket has a deficit. Deduct the full chunk's bytes — the bucket
|
|
167
|
+
// goes negative — and tell the caller to wait for the deficit to
|
|
168
|
+
// refill. Subsequent _refill() calls re-accumulate from there, so
|
|
169
|
+
// the next consume sees an accurate budget. A parallel transform
|
|
170
|
+
// hitting the same bucket while it is negative also waits.
|
|
171
|
+
var deficitBytes = bytes - tokens;
|
|
172
|
+
var waitMs = Math.ceil((deficitBytes / bytesPerSec) * MS_PER_SECOND);
|
|
173
|
+
tokens -= bytes;
|
|
174
|
+
return waitMs;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function transform(tOpts) {
|
|
178
|
+
tOpts = tOpts || {};
|
|
179
|
+
var allowOversize = tOpts.allowOversize === true;
|
|
180
|
+
// Per-chunk wait ceiling. A misconfigured operator passing
|
|
181
|
+
// chunkBytes / bytesPerSec ratios that schedule a 10-minute
|
|
182
|
+
// single-chunk wait would otherwise pin the pipeline silently;
|
|
183
|
+
// when `maxWaitMs` is set, any computed wait > maxWaitMs refuses
|
|
184
|
+
// the chunk with `stream-throttle/wait-exceeds-max`. Defaults to
|
|
185
|
+
// omitted (no ceiling) for back-compat with operators wanting
|
|
186
|
+
// the historical "wait however long" behavior.
|
|
187
|
+
var maxWaitMs = tOpts.maxWaitMs;
|
|
188
|
+
if (maxWaitMs !== undefined &&
|
|
189
|
+
(typeof maxWaitMs !== "number" || !isFinite(maxWaitMs) || maxWaitMs <= 0)) {
|
|
190
|
+
throw new StreamThrottleError("stream-throttle/bad-max-wait",
|
|
191
|
+
"transform: maxWaitMs must be a finite number > 0, got " + maxWaitMs);
|
|
192
|
+
}
|
|
193
|
+
return new nodeStream.Transform({
|
|
194
|
+
transform: function (chunk, _enc, cb) {
|
|
195
|
+
var buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
196
|
+
var bytes = buf.length;
|
|
197
|
+
var waitMs;
|
|
198
|
+
try { waitMs = _consume(bytes, allowOversize); }
|
|
199
|
+
catch (e) { cb(e); return; }
|
|
200
|
+
if (maxWaitMs !== undefined && waitMs > maxWaitMs) {
|
|
201
|
+
cb(new StreamThrottleError("stream-throttle/wait-exceeds-max",
|
|
202
|
+
"computed wait " + waitMs + "ms exceeds maxWaitMs=" + maxWaitMs +
|
|
203
|
+
" (chunk=" + bytes + " bytes, rate=" + bytesPerSec + " bytes/s) — " +
|
|
204
|
+
"reduce chunk size, increase rate, or raise maxWaitMs"));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (waitMs === 0) { cb(null, buf); return; }
|
|
208
|
+
setTimeout(function () { cb(null, buf); }, waitMs);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function state() {
|
|
214
|
+
_refill();
|
|
215
|
+
return {
|
|
216
|
+
bytesPerSec: bytesPerSec,
|
|
217
|
+
burstBytes: burstBytes,
|
|
218
|
+
tokens: tokens,
|
|
219
|
+
lastRefillMs: lastRefill,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { transform: transform, state: state };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function _hrtimeMs() {
|
|
227
|
+
// hrtime returns [s, ns] integer pair; convert to ms float.
|
|
228
|
+
var t = process.hrtime();
|
|
229
|
+
return t[0] * MS_PER_SECOND_HRTIME + t[1] / NS_PER_MS;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
create: create,
|
|
234
|
+
StreamThrottleError: StreamThrottleError,
|
|
235
|
+
};
|
package/lib/subject.js
CHANGED
|
@@ -635,16 +635,20 @@ function _subjectHash(subjectId) {
|
|
|
635
635
|
|
|
636
636
|
function _writeAudit(action, subjectId, outcome, metadata) {
|
|
637
637
|
// recordSafe — audit failure must not roll back the subject mutation
|
|
638
|
-
// that already touched the database.
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
638
|
+
// that already touched the database. Drop-silent per CLAUDE.md rule
|
|
639
|
+
// §5 (hot-path audit sinks): swallow any throw from audit.emit so a
|
|
640
|
+
// misconfigured sink doesn't crash a partially-committed subject
|
|
641
|
+
// mutation. Errors surface via the audit sink's own logger.
|
|
642
|
+
try {
|
|
643
|
+
audit.emit({
|
|
644
|
+
actor: {},
|
|
645
|
+
action: action,
|
|
646
|
+
resource: { kind: "subject", id: subjectId },
|
|
647
|
+
outcome: outcome,
|
|
648
|
+
reason: metadata && metadata.requestReason ? metadata.requestReason : null,
|
|
649
|
+
metadata: metadata || null,
|
|
650
|
+
});
|
|
651
|
+
} catch (_e) { /* drop-silent — audit emit failure must not block subject mutation */ }
|
|
648
652
|
}
|
|
649
653
|
|
|
650
654
|
function _resetForTest() { db.reset(); }
|
package/package.json
CHANGED
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:
|
|
5
|
+
"serialNumber": "urn:uuid:538b90b1-594c-4130-b06c-a5a105757062",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-18T21:01:40.359Z",
|
|
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.10.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.10.14",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.10.
|
|
25
|
+
"version": "0.10.14",
|
|
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.10.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.10.14",
|
|
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.10.
|
|
57
|
+
"ref": "@blamejs/core@0.10.14",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|