@blamejs/core 0.10.11 → 0.10.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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.10.11",
3
+ "version": "0.10.13",
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:2bcbf942-6319-4d65-a08c-1e385ac35198",
5
+ "serialNumber": "urn:uuid:7f2411e6-1488-4a9f-954b-bef06640070c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-18T04:40:34.689Z",
8
+ "timestamp": "2026-05-18T20:01:39.367Z",
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.11",
22
+ "bom-ref": "@blamejs/core@0.10.13",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.10.11",
25
+ "version": "0.10.13",
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.11",
29
+ "purl": "pkg:npm/%40blamejs/core@0.10.13",
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.11",
57
+ "ref": "@blamejs/core@0.10.13",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]