@blamejs/core 0.4.1
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 +230 -0
- package/LICENSE +201 -0
- package/LTS-CALENDAR.md +29 -0
- package/MIGRATING.md +7 -0
- package/NOTICE +59 -0
- package/README.md +100 -0
- package/bin/blamejs.js +13 -0
- package/index.js +253 -0
- package/lib/api-key.js +705 -0
- package/lib/api-snapshot.js +335 -0
- package/lib/app-shutdown.js +381 -0
- package/lib/app.js +364 -0
- package/lib/atomic-file.js +525 -0
- package/lib/audit-chain.js +168 -0
- package/lib/audit-sign.js +319 -0
- package/lib/audit-tools.js +682 -0
- package/lib/audit.js +753 -0
- package/lib/auth/jwt.js +280 -0
- package/lib/auth/oauth.js +691 -0
- package/lib/auth/passkey.js +185 -0
- package/lib/auth/password.js +139 -0
- package/lib/auth/totp.js +17 -0
- package/lib/auth-header.js +81 -0
- package/lib/backup/bundle.js +219 -0
- package/lib/backup/crypto.js +174 -0
- package/lib/backup/index.js +490 -0
- package/lib/backup/manifest.js +275 -0
- package/lib/bundler.js +295 -0
- package/lib/cache.js +819 -0
- package/lib/chain-writer.js +234 -0
- package/lib/cli-helpers.js +201 -0
- package/lib/cli.js +1377 -0
- package/lib/cluster-provider-db.js +245 -0
- package/lib/cluster-storage.js +166 -0
- package/lib/cluster.js +691 -0
- package/lib/consent.js +222 -0
- package/lib/constants.js +186 -0
- package/lib/cookies.js +293 -0
- package/lib/credential-hash.js +303 -0
- package/lib/crypto-field.js +159 -0
- package/lib/crypto.js +250 -0
- package/lib/db-query.js +297 -0
- package/lib/db-schema.js +250 -0
- package/lib/db.js +1054 -0
- package/lib/deprecate.js +226 -0
- package/lib/dev.js +324 -0
- package/lib/error-page.js +424 -0
- package/lib/events.js +135 -0
- package/lib/external-db.js +422 -0
- package/lib/forms.js +378 -0
- package/lib/framework-error.js +189 -0
- package/lib/framework-schema.js +604 -0
- package/lib/handlers.js +350 -0
- package/lib/html-balance.js +227 -0
- package/lib/http-client.js +615 -0
- package/lib/i18n.js +780 -0
- package/lib/jobs.js +181 -0
- package/lib/lazy-require.js +48 -0
- package/lib/log-stream-local.js +137 -0
- package/lib/log-stream-webhook.js +170 -0
- package/lib/log-stream.js +211 -0
- package/lib/log.js +355 -0
- package/lib/mail-bounce.js +507 -0
- package/lib/mail.js +701 -0
- package/lib/metrics.js +647 -0
- package/lib/middleware/api-encrypt.js +553 -0
- package/lib/middleware/attach-user.js +156 -0
- package/lib/middleware/body-parser.js +883 -0
- package/lib/middleware/bot-guard.js +148 -0
- package/lib/middleware/compression.js +436 -0
- package/lib/middleware/cors.js +236 -0
- package/lib/middleware/csp-nonce.js +332 -0
- package/lib/middleware/csrf-protect.js +275 -0
- package/lib/middleware/error-handler.js +46 -0
- package/lib/middleware/health.js +358 -0
- package/lib/middleware/index.js +52 -0
- package/lib/middleware/rate-limit.js +319 -0
- package/lib/middleware/request-id.js +53 -0
- package/lib/middleware/require-auth.js +95 -0
- package/lib/middleware/security-headers.js +91 -0
- package/lib/migrations.js +353 -0
- package/lib/mtls-ca.js +333 -0
- package/lib/mtls-engine-default.js +285 -0
- package/lib/nonce-store.js +177 -0
- package/lib/notify.js +643 -0
- package/lib/ntp-check.js +178 -0
- package/lib/object-store/azure-blob.js +467 -0
- package/lib/object-store/gcs.js +469 -0
- package/lib/object-store/http-put.js +153 -0
- package/lib/object-store/index.js +140 -0
- package/lib/object-store/local.js +163 -0
- package/lib/object-store/retry.js +15 -0
- package/lib/object-store/sigv4.js +535 -0
- package/lib/observability.js +114 -0
- package/lib/pagination.js +371 -0
- package/lib/parsers/index.js +64 -0
- package/lib/parsers/safe-csv.js +224 -0
- package/lib/parsers/safe-env.js +614 -0
- package/lib/parsers/safe-toml.js +745 -0
- package/lib/parsers/safe-xml.js +379 -0
- package/lib/parsers/safe-yaml.js +977 -0
- package/lib/permissions.js +430 -0
- package/lib/pqc-agent.js +85 -0
- package/lib/pqc-gate.js +266 -0
- package/lib/protocol-dispatcher.js +144 -0
- package/lib/queue-local.js +327 -0
- package/lib/queue.js +430 -0
- package/lib/redact.js +192 -0
- package/lib/render.js +193 -0
- package/lib/request-helpers.js +178 -0
- package/lib/restore-bundle.js +239 -0
- package/lib/restore-rollback.js +254 -0
- package/lib/restore.js +301 -0
- package/lib/retry.js +329 -0
- package/lib/router.js +437 -0
- package/lib/safe-async.js +520 -0
- package/lib/safe-buffer.js +162 -0
- package/lib/safe-json.js +532 -0
- package/lib/safe-schema.js +1176 -0
- package/lib/safe-sql.js +157 -0
- package/lib/safe-url.js +109 -0
- package/lib/scheduler.js +680 -0
- package/lib/seeders.js +622 -0
- package/lib/session.js +304 -0
- package/lib/slug.js +243 -0
- package/lib/static.js +268 -0
- package/lib/storage.js +470 -0
- package/lib/subject.js +281 -0
- package/lib/template.js +781 -0
- package/lib/testing.js +621 -0
- package/lib/totp.js +285 -0
- package/lib/tracing.js +484 -0
- package/lib/validate-opts.js +56 -0
- package/lib/vault/index.js +299 -0
- package/lib/vault/passphrase-ops.js +311 -0
- package/lib/vault/passphrase-source.js +198 -0
- package/lib/vault/rotate.js +761 -0
- package/lib/vault/wrap.js +289 -0
- package/lib/vendor/MANIFEST.json +84 -0
- package/lib/vendor/argon2/argon2.cjs +466 -0
- package/lib/vendor/argon2/argon2.d.cts +62 -0
- package/lib/vendor/argon2/package.json +1 -0
- package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
- package/lib/vendor/noble-ciphers.cjs +9 -0
- package/lib/vendor/pki.cjs +181 -0
- package/lib/vendor/simplewebauthn-server.cjs +328 -0
- package/lib/webhook.js +632 -0
- package/lib/websocket-channels.js +413 -0
- package/lib/websocket.js +833 -0
- package/package.json +39 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Async resilience + safety primitives.
|
|
4
|
+
*
|
|
5
|
+
* The framework's async surfaces (external-db queries, cluster
|
|
6
|
+
* coordination, queue operations, audit chain writes) all share the
|
|
7
|
+
* same hazards: races between interleaved awaits, unbounded retries
|
|
8
|
+
* masking real failures, hangs from unresponsive backends, and partial
|
|
9
|
+
* results from operator-supplied drivers. This module collects the
|
|
10
|
+
* primitives the framework uses to handle those hazards consistently.
|
|
11
|
+
*
|
|
12
|
+
* Design posture:
|
|
13
|
+
*
|
|
14
|
+
* - **AbortSignal everywhere.** Every primitive that takes time
|
|
15
|
+
* accepts an `AbortSignal` and aborts cleanly when the signal
|
|
16
|
+
* fires. This is the modern Node.js convention (Node 18+) and
|
|
17
|
+
* replaces the older "cancellation token" pattern. Operators who
|
|
18
|
+
* don't pass a signal get the legacy non-cancellable behaviour.
|
|
19
|
+
*
|
|
20
|
+
* - **Error.cause preserved.** Wrapper errors set `.cause` to the
|
|
21
|
+
* original failure so debugging traces back to the root. Callers
|
|
22
|
+
* who walk `.cause` chains see the full picture.
|
|
23
|
+
*
|
|
24
|
+
* - **No leaked Promises.** Mutex / Semaphore release on path-out
|
|
25
|
+
* in finally blocks — even cancellation. No pending acquirer
|
|
26
|
+
* stays referenced after its abort.
|
|
27
|
+
*
|
|
28
|
+
* - **Bounded by default.** Semaphore / Queue have explicit limits
|
|
29
|
+
* and reject acquisitions over the limit rather than growing
|
|
30
|
+
* unboundedly. Operators size limits explicitly for their workload.
|
|
31
|
+
*
|
|
32
|
+
* - **Fail loud.** Errors propagate; primitives never silently
|
|
33
|
+
* swallow. safeAwait() opt-in for callers who need {error, value}
|
|
34
|
+
* tuples; everything else throws / rejects.
|
|
35
|
+
*
|
|
36
|
+
* Public API:
|
|
37
|
+
*
|
|
38
|
+
* withTimeout(promise, ms, opts?) promise; rejects on timeout
|
|
39
|
+
* withSignal(promise, signal) promise; rejects on abort
|
|
40
|
+
* withTimeoutSignal(signal, ms) AbortSignal composing user
|
|
41
|
+
* signal + a fresh timeout. Used
|
|
42
|
+
* by I/O primitives that already
|
|
43
|
+
* accept a signal and want to
|
|
44
|
+
* add a wall-clock deadline.
|
|
45
|
+
* sleep(ms, opts?) promise that resolves after ms;
|
|
46
|
+
* opts.signal aborts mid-sleep,
|
|
47
|
+
* timer is unref'd so a pending
|
|
48
|
+
* sleep doesn't keep the process
|
|
49
|
+
* alive
|
|
50
|
+
* safeAwait(promise) [error, value] never throws
|
|
51
|
+
*
|
|
52
|
+
* Mutex class; .runExclusive(fn)
|
|
53
|
+
* Semaphore(limit) class; .runWith(fn)
|
|
54
|
+
* Once(fn) class; .invoke()
|
|
55
|
+
*
|
|
56
|
+
* asyncRetry(fn, opts?) re-export from object-store-retry
|
|
57
|
+
* CircuitBreaker(name, opts?) re-export from object-store-retry
|
|
58
|
+
*
|
|
59
|
+
* SafeAsyncError error class
|
|
60
|
+
*
|
|
61
|
+
* Best-practice notes for callers:
|
|
62
|
+
*
|
|
63
|
+
* - Always pair `withTimeout` with the external-db / network calls
|
|
64
|
+
* where operator-supplied drivers might hang. The framework's
|
|
65
|
+
* external-db wrapper already retries; timeout puts a ceiling on
|
|
66
|
+
* each individual attempt.
|
|
67
|
+
*
|
|
68
|
+
* - Wrap chain-writes with Mutex.runExclusive. Audit chain hashing
|
|
69
|
+
* reads the previous tip and writes a successor; without
|
|
70
|
+
* serialization, concurrent awaiting record() calls can hash
|
|
71
|
+
* against the same prev-tip and produce a forked chain. Mutex
|
|
72
|
+
* prevents this in single-process; for cross-process coordination
|
|
73
|
+
* the cluster module's leader election is the correct primitive.
|
|
74
|
+
*
|
|
75
|
+
* - Use Once for boot-time lazy init (counter primer, schema
|
|
76
|
+
* check). Multiple concurrent first-callers correctly all wait
|
|
77
|
+
* on the same in-flight init Promise rather than each starting
|
|
78
|
+
* their own.
|
|
79
|
+
*
|
|
80
|
+
* - Use safeAwait for fire-and-forget paths (audit hooks in
|
|
81
|
+
* middleware) that previously used try/catch — preserves the
|
|
82
|
+
* "log + continue" pattern without unhandled-rejection warnings.
|
|
83
|
+
*
|
|
84
|
+
* - Prefer Promise.allSettled over Promise.all when partial failure
|
|
85
|
+
* is acceptable (e.g. emitting to multiple log sinks; one sink
|
|
86
|
+
* down shouldn't block the others). The framework's log-stream
|
|
87
|
+
* dispatcher already does this.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
var { FrameworkError } = require("./framework-error");
|
|
91
|
+
|
|
92
|
+
class SafeAsyncError extends FrameworkError {
|
|
93
|
+
constructor(message, code, cause) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.name = "SafeAsyncError";
|
|
96
|
+
this.code = code || "async/invalid";
|
|
97
|
+
if (cause !== undefined) this.cause = cause;
|
|
98
|
+
this.isSafeAsyncError = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- withTimeout ----
|
|
103
|
+
//
|
|
104
|
+
// Race the promise against a timer. On timeout, the wrapper rejects with
|
|
105
|
+
// SafeAsyncError(code=async/timeout). The original promise continues
|
|
106
|
+
// running in the background — the framework cannot cancel an arbitrary
|
|
107
|
+
// async operation; only signal-aware ones can be aborted (see withSignal).
|
|
108
|
+
//
|
|
109
|
+
// opts.signal: AbortSignal — aborting the signal also rejects the wrapper
|
|
110
|
+
// with code=async/aborted.
|
|
111
|
+
// opts.name: diagnostic label included in the timeout message.
|
|
112
|
+
|
|
113
|
+
function withTimeout(promise, ms, opts) {
|
|
114
|
+
opts = opts || {};
|
|
115
|
+
if (typeof ms !== "number" || ms <= 0 || !Number.isFinite(ms)) {
|
|
116
|
+
throw new SafeAsyncError("withTimeout: ms must be a positive finite number", "async/bad-arg");
|
|
117
|
+
}
|
|
118
|
+
return new Promise(function (resolve, reject) {
|
|
119
|
+
var settled = false;
|
|
120
|
+
// The timer is ref'd intentionally: while a withTimeout is pending,
|
|
121
|
+
// the process should stay alive until either the underlying promise
|
|
122
|
+
// settles or the timeout fires. unref'ing here would let Node exit
|
|
123
|
+
// mid-await and the awaited Promise would never resolve.
|
|
124
|
+
var timer = setTimeout(function () {
|
|
125
|
+
if (settled) return;
|
|
126
|
+
settled = true;
|
|
127
|
+
reject(new SafeAsyncError(
|
|
128
|
+
"operation timed out after " + ms + "ms" + (opts.name ? " (" + opts.name + ")" : ""),
|
|
129
|
+
"async/timeout"
|
|
130
|
+
));
|
|
131
|
+
}, ms);
|
|
132
|
+
|
|
133
|
+
function _onAbort() {
|
|
134
|
+
if (settled) return;
|
|
135
|
+
settled = true;
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
reject(new SafeAsyncError(
|
|
138
|
+
"operation aborted" + (opts.name ? " (" + opts.name + ")" : ""),
|
|
139
|
+
"async/aborted",
|
|
140
|
+
opts.signal && opts.signal.reason
|
|
141
|
+
));
|
|
142
|
+
}
|
|
143
|
+
if (opts.signal) {
|
|
144
|
+
if (opts.signal.aborted) { _onAbort(); return; }
|
|
145
|
+
opts.signal.addEventListener("abort", _onAbort, { once: true });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
Promise.resolve(promise).then(function (v) {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
settled = true;
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
if (opts.signal) opts.signal.removeEventListener("abort", _onAbort);
|
|
153
|
+
resolve(v);
|
|
154
|
+
}, function (e) {
|
|
155
|
+
if (settled) return;
|
|
156
|
+
settled = true;
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
if (opts.signal) opts.signal.removeEventListener("abort", _onAbort);
|
|
159
|
+
reject(e);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- withSignal ----
|
|
165
|
+
//
|
|
166
|
+
// Race the promise against an AbortSignal. The original promise continues
|
|
167
|
+
// running in the background; only the wrapper's resolution is short-
|
|
168
|
+
// circuited. Useful for plumbing a single signal through a chain of awaits.
|
|
169
|
+
|
|
170
|
+
function withSignal(promise, signal) {
|
|
171
|
+
if (!signal) return Promise.resolve(promise);
|
|
172
|
+
return new Promise(function (resolve, reject) {
|
|
173
|
+
var settled = false;
|
|
174
|
+
function _onAbort() {
|
|
175
|
+
if (settled) return;
|
|
176
|
+
settled = true;
|
|
177
|
+
reject(new SafeAsyncError(
|
|
178
|
+
"operation aborted",
|
|
179
|
+
"async/aborted",
|
|
180
|
+
signal.reason
|
|
181
|
+
));
|
|
182
|
+
}
|
|
183
|
+
if (signal.aborted) { _onAbort(); return; }
|
|
184
|
+
signal.addEventListener("abort", _onAbort, { once: true });
|
|
185
|
+
Promise.resolve(promise).then(function (v) {
|
|
186
|
+
if (settled) return;
|
|
187
|
+
settled = true;
|
|
188
|
+
signal.removeEventListener("abort", _onAbort);
|
|
189
|
+
resolve(v);
|
|
190
|
+
}, function (e) {
|
|
191
|
+
if (settled) return;
|
|
192
|
+
settled = true;
|
|
193
|
+
signal.removeEventListener("abort", _onAbort);
|
|
194
|
+
reject(e);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---- sleep ----
|
|
200
|
+
//
|
|
201
|
+
// Promise that resolves after `ms` milliseconds. opts.signal aborts the
|
|
202
|
+
// sleep cleanly — the wrapper rejects with SafeAsyncError(async/aborted).
|
|
203
|
+
//
|
|
204
|
+
// opts.unref (default false): if true, the timer is unref'd so a pending
|
|
205
|
+
// sleep does NOT keep the process alive. Use for fire-and-forget /
|
|
206
|
+
// heartbeat-style patterns where the program should be free to exit if
|
|
207
|
+
// nothing else is keeping it busy. The default is ref'd to match the
|
|
208
|
+
// natural meaning of `await sleep(ms)` — "I'm waiting, this IS my work,
|
|
209
|
+
// don't exit out from under me." (An unref'd-only event loop in Node
|
|
210
|
+
// exits even with pending awaits — the unref'd timer is not enough to
|
|
211
|
+
// hold the loop alive, so the awaiting promise never resolves.)
|
|
212
|
+
//
|
|
213
|
+
// ms <= 0 resolves immediately (matches setTimeout's clamp-to-1ms but
|
|
214
|
+
// without the wasted tick). Non-finite ms rejects.
|
|
215
|
+
|
|
216
|
+
function sleep(ms, opts) {
|
|
217
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
|
218
|
+
return Promise.reject(new SafeAsyncError(
|
|
219
|
+
"sleep: ms must be a finite number", "async/bad-arg"
|
|
220
|
+
));
|
|
221
|
+
}
|
|
222
|
+
if (ms <= 0) return Promise.resolve();
|
|
223
|
+
|
|
224
|
+
var signal = opts && opts.signal;
|
|
225
|
+
if (signal && signal.aborted) {
|
|
226
|
+
return Promise.reject(new SafeAsyncError(
|
|
227
|
+
"sleep aborted before start", "async/aborted", signal.reason
|
|
228
|
+
));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return new Promise(function (resolve, reject) {
|
|
232
|
+
var settled = false;
|
|
233
|
+
var timer = setTimeout(function () {
|
|
234
|
+
if (settled) return;
|
|
235
|
+
settled = true;
|
|
236
|
+
if (signal) signal.removeEventListener("abort", _onAbort);
|
|
237
|
+
resolve();
|
|
238
|
+
}, ms);
|
|
239
|
+
if (opts && opts.unref) timer.unref();
|
|
240
|
+
|
|
241
|
+
function _onAbort() {
|
|
242
|
+
if (settled) return;
|
|
243
|
+
settled = true;
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
reject(new SafeAsyncError(
|
|
246
|
+
"sleep aborted", "async/aborted", signal.reason
|
|
247
|
+
));
|
|
248
|
+
}
|
|
249
|
+
if (signal) signal.addEventListener("abort", _onAbort, { once: true });
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- withTimeoutSignal ----
|
|
254
|
+
//
|
|
255
|
+
// Returns an AbortSignal that fires when EITHER the input signal aborts
|
|
256
|
+
// OR `ms` milliseconds elapse. Wraps the I/O-primitive composition that
|
|
257
|
+
// http-client was doing inline twice (h1 + h2 paths).
|
|
258
|
+
//
|
|
259
|
+
// var sig = withTimeoutSignal(userSignal, 5000);
|
|
260
|
+
// await fetch(url, { signal: sig });
|
|
261
|
+
//
|
|
262
|
+
// Edge cases:
|
|
263
|
+
// - userSignal == null and ms not positive → returns null
|
|
264
|
+
// (caller's "no signal needed" path)
|
|
265
|
+
// - userSignal == null → returns AbortSignal.timeout(ms)
|
|
266
|
+
// - ms not positive (0/undefined) → returns userSignal unchanged
|
|
267
|
+
// - both → AbortSignal.any([user, timeout])
|
|
268
|
+
//
|
|
269
|
+
// Returning null when neither is requested means the caller can pass
|
|
270
|
+
// the result straight to APIs that treat null as "no abort" (the http
|
|
271
|
+
// `signal` option does), with no special-case branching needed.
|
|
272
|
+
|
|
273
|
+
function withTimeoutSignal(signal, ms) {
|
|
274
|
+
var hasTimeout = typeof ms === "number" && ms > 0 && Number.isFinite(ms);
|
|
275
|
+
if (!signal && !hasTimeout) return null;
|
|
276
|
+
if (!signal) return AbortSignal.timeout(ms);
|
|
277
|
+
if (!hasTimeout) return signal;
|
|
278
|
+
return AbortSignal.any([signal, AbortSignal.timeout(ms)]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- safeAwait ----
|
|
282
|
+
//
|
|
283
|
+
// Go-style [error, value] tuple. Never throws. Lets callers handle the
|
|
284
|
+
// "expected may fail; log and continue" pattern without try/catch
|
|
285
|
+
// scaffolding.
|
|
286
|
+
//
|
|
287
|
+
// var [err, value] = await safeAwait(somePromise);
|
|
288
|
+
// if (err) { /* log + continue */ }
|
|
289
|
+
|
|
290
|
+
async function safeAwait(promise) {
|
|
291
|
+
try {
|
|
292
|
+
var v = await promise;
|
|
293
|
+
return [null, v];
|
|
294
|
+
} catch (e) {
|
|
295
|
+
return [e, null];
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---- Mutex ----
|
|
300
|
+
//
|
|
301
|
+
// Async mutex — only one async region holds the lock at a time. Acquirers
|
|
302
|
+
// queue in arrival order. .runExclusive(fn) is the recommended call form
|
|
303
|
+
// (lock release is automatic via finally even if fn throws); .acquire()
|
|
304
|
+
// + .release() are exposed for callers needing finer control.
|
|
305
|
+
//
|
|
306
|
+
// Implementation note: a queued acquirer that's never released (operator
|
|
307
|
+
// bug) blocks the entire mutex. We don't add a hard-coded timeout because
|
|
308
|
+
// timeouts mask bugs and the real fix is releasing properly. If a caller
|
|
309
|
+
// wants a deadline, wrap the runExclusive call with withTimeout.
|
|
310
|
+
|
|
311
|
+
class Mutex {
|
|
312
|
+
constructor() {
|
|
313
|
+
this._waiters = []; // [{ resolve, reject, signal, onAbort }]
|
|
314
|
+
this._held = false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// acquire(opts?) — opts.signal aborts a waiting acquirer cleanly.
|
|
318
|
+
// When the signal fires, the waiter is removed from the queue and
|
|
319
|
+
// its Promise rejects with async/aborted. The slot it WOULD have
|
|
320
|
+
// taken stays free for the next waiter. If the mutex isn't held,
|
|
321
|
+
// acquire returns immediately regardless of signal state.
|
|
322
|
+
acquire(opts) {
|
|
323
|
+
var self = this;
|
|
324
|
+
var signal = opts && opts.signal;
|
|
325
|
+
if (!self._held) {
|
|
326
|
+
self._held = true;
|
|
327
|
+
return Promise.resolve();
|
|
328
|
+
}
|
|
329
|
+
if (signal && signal.aborted) {
|
|
330
|
+
return Promise.reject(new SafeAsyncError(
|
|
331
|
+
"Mutex.acquire aborted", "async/aborted", signal.reason
|
|
332
|
+
));
|
|
333
|
+
}
|
|
334
|
+
return new Promise(function (resolve, reject) {
|
|
335
|
+
var entry = { resolve: resolve, reject: reject, signal: signal, onAbort: null };
|
|
336
|
+
if (signal) {
|
|
337
|
+
entry.onAbort = function () {
|
|
338
|
+
var idx = self._waiters.indexOf(entry);
|
|
339
|
+
if (idx === -1) return; // already taken or already aborted
|
|
340
|
+
self._waiters.splice(idx, 1);
|
|
341
|
+
reject(new SafeAsyncError(
|
|
342
|
+
"Mutex.acquire aborted while waiting", "async/aborted", signal.reason
|
|
343
|
+
));
|
|
344
|
+
};
|
|
345
|
+
signal.addEventListener("abort", entry.onAbort, { once: true });
|
|
346
|
+
}
|
|
347
|
+
self._waiters.push(entry);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
release() {
|
|
352
|
+
if (!this._held) {
|
|
353
|
+
throw new SafeAsyncError("release on unheld Mutex", "async/bad-release");
|
|
354
|
+
}
|
|
355
|
+
if (this._waiters.length > 0) {
|
|
356
|
+
var next = this._waiters.shift();
|
|
357
|
+
if (next.signal && next.onAbort) {
|
|
358
|
+
next.signal.removeEventListener("abort", next.onAbort);
|
|
359
|
+
}
|
|
360
|
+
next.resolve();
|
|
361
|
+
} else {
|
|
362
|
+
this._held = false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async runExclusive(fn, opts) {
|
|
367
|
+
await this.acquire(opts);
|
|
368
|
+
try {
|
|
369
|
+
return await fn();
|
|
370
|
+
} finally {
|
|
371
|
+
this.release();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
isHeld() { return this._held; }
|
|
376
|
+
pendingCount() { return this._waiters.length; }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- Semaphore ----
|
|
380
|
+
//
|
|
381
|
+
// Bounded concurrency: at most `limit` simultaneous holders. Acquirers
|
|
382
|
+
// over the limit wait their turn. Use cases: limit external-db query
|
|
383
|
+
// concurrency, throttle outbound webhook fan-out, cap parallel file I/O.
|
|
384
|
+
//
|
|
385
|
+
// .runWith(fn) is the recommended form (release on finally); .acquire/
|
|
386
|
+
// .release are exposed for finer control.
|
|
387
|
+
|
|
388
|
+
class Semaphore {
|
|
389
|
+
constructor(limit) {
|
|
390
|
+
if (typeof limit !== "number" || limit < 1 || !Number.isInteger(limit)) {
|
|
391
|
+
throw new SafeAsyncError("Semaphore limit must be a positive integer", "async/bad-arg");
|
|
392
|
+
}
|
|
393
|
+
this._limit = limit;
|
|
394
|
+
this._inFlight = 0;
|
|
395
|
+
this._waiters = []; // [{ resolve, reject, signal, onAbort }]
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// acquire(opts?) — opts.signal aborts a waiting acquirer cleanly.
|
|
399
|
+
acquire(opts) {
|
|
400
|
+
var self = this;
|
|
401
|
+
var signal = opts && opts.signal;
|
|
402
|
+
if (self._inFlight < self._limit) {
|
|
403
|
+
self._inFlight += 1;
|
|
404
|
+
return Promise.resolve();
|
|
405
|
+
}
|
|
406
|
+
if (signal && signal.aborted) {
|
|
407
|
+
return Promise.reject(new SafeAsyncError(
|
|
408
|
+
"Semaphore.acquire aborted", "async/aborted", signal.reason
|
|
409
|
+
));
|
|
410
|
+
}
|
|
411
|
+
return new Promise(function (resolve, reject) {
|
|
412
|
+
var entry = { resolve: resolve, reject: reject, signal: signal, onAbort: null };
|
|
413
|
+
if (signal) {
|
|
414
|
+
entry.onAbort = function () {
|
|
415
|
+
var idx = self._waiters.indexOf(entry);
|
|
416
|
+
if (idx === -1) return;
|
|
417
|
+
self._waiters.splice(idx, 1);
|
|
418
|
+
reject(new SafeAsyncError(
|
|
419
|
+
"Semaphore.acquire aborted while waiting", "async/aborted", signal.reason
|
|
420
|
+
));
|
|
421
|
+
};
|
|
422
|
+
signal.addEventListener("abort", entry.onAbort, { once: true });
|
|
423
|
+
}
|
|
424
|
+
self._waiters.push(entry);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
release() {
|
|
429
|
+
if (this._inFlight === 0) {
|
|
430
|
+
throw new SafeAsyncError("release on idle Semaphore", "async/bad-release");
|
|
431
|
+
}
|
|
432
|
+
if (this._waiters.length > 0) {
|
|
433
|
+
var next = this._waiters.shift();
|
|
434
|
+
if (next.signal && next.onAbort) {
|
|
435
|
+
next.signal.removeEventListener("abort", next.onAbort);
|
|
436
|
+
}
|
|
437
|
+
next.resolve();
|
|
438
|
+
} else {
|
|
439
|
+
this._inFlight -= 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async runWith(fn, opts) {
|
|
444
|
+
await this.acquire(opts);
|
|
445
|
+
try {
|
|
446
|
+
return await fn();
|
|
447
|
+
} finally {
|
|
448
|
+
this.release();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
inFlight() { return this._inFlight; }
|
|
453
|
+
pendingCount() { return this._waiters.length; }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---- Once ----
|
|
457
|
+
//
|
|
458
|
+
// Single-flight memoization. The first .invoke() call runs the function;
|
|
459
|
+
// subsequent calls (concurrent or later) await the same in-flight Promise
|
|
460
|
+
// and receive the same result. If the first invocation throws, the cached
|
|
461
|
+
// result is the rejected Promise — re-invocation will NOT retry.
|
|
462
|
+
//
|
|
463
|
+
// Use case: lazy boot-time init where multiple call sites might first-
|
|
464
|
+
// touch concurrently (counter primer, schema-check, key load). Without
|
|
465
|
+
// Once, the second concurrent caller would start its own init and produce
|
|
466
|
+
// double-initialization.
|
|
467
|
+
//
|
|
468
|
+
// .reset() clears the cached Promise so the next .invoke() runs fresh.
|
|
469
|
+
// Useful for tests, hot-reload, and operator-driven re-init after a
|
|
470
|
+
// transient init failure. To make resets explicit (avoid silent stale
|
|
471
|
+
// state), reset() does NOT cancel an in-flight first invocation —
|
|
472
|
+
// callers awaiting the prior invoke continue to see its result.
|
|
473
|
+
|
|
474
|
+
class Once {
|
|
475
|
+
constructor(fn) {
|
|
476
|
+
if (typeof fn !== "function") {
|
|
477
|
+
throw new SafeAsyncError("Once: argument must be a function", "async/bad-arg");
|
|
478
|
+
}
|
|
479
|
+
this._fn = fn;
|
|
480
|
+
this._promise = null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
invoke() {
|
|
484
|
+
if (this._promise === null) {
|
|
485
|
+
this._promise = Promise.resolve().then(this._fn);
|
|
486
|
+
}
|
|
487
|
+
return this._promise;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
reset() {
|
|
491
|
+
this._promise = null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
hasInvoked() { return this._promise !== null; }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---- Re-exports of resilience primitives from lib/retry.js ----
|
|
498
|
+
//
|
|
499
|
+
// withRetry + CircuitBreaker live in lib/retry.js (the canonical home).
|
|
500
|
+
// We re-export them here under safe-async-shaped names so call sites
|
|
501
|
+
// reaching for async safety primitives find them in one place.
|
|
502
|
+
|
|
503
|
+
var retryHelper = require("./retry");
|
|
504
|
+
|
|
505
|
+
var asyncRetry = retryHelper.withRetry;
|
|
506
|
+
var CircuitBreaker = retryHelper.CircuitBreaker;
|
|
507
|
+
|
|
508
|
+
module.exports = {
|
|
509
|
+
withTimeout: withTimeout,
|
|
510
|
+
withSignal: withSignal,
|
|
511
|
+
withTimeoutSignal: withTimeoutSignal,
|
|
512
|
+
sleep: sleep,
|
|
513
|
+
safeAwait: safeAwait,
|
|
514
|
+
Mutex: Mutex,
|
|
515
|
+
Semaphore: Semaphore,
|
|
516
|
+
Once: Once,
|
|
517
|
+
asyncRetry: asyncRetry,
|
|
518
|
+
CircuitBreaker: CircuitBreaker,
|
|
519
|
+
SafeAsyncError: SafeAsyncError,
|
|
520
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Buffer-safety primitives — centralizes the input-normalize, capped
|
|
4
|
+
* chunk collection, and secure-zero patterns that were scattered across
|
|
5
|
+
* lib/parsers/*, lib/atomic-file.js, lib/object-store-*.js, and
|
|
6
|
+
* lib/log-stream-*.js.
|
|
7
|
+
*
|
|
8
|
+
* Public API:
|
|
9
|
+
* safeBuffer.normalizeText(input, { maxBytes, stripBom, errorClass })
|
|
10
|
+
* Accept string | Buffer | Uint8Array → returns string. Strips a
|
|
11
|
+
* leading UTF-8 BOM (U+FEFF) by default. Throws errorClass(message,
|
|
12
|
+
* code) if input is the wrong type or exceeds maxBytes.
|
|
13
|
+
*
|
|
14
|
+
* safeBuffer.toBuffer(data, { maxBytes, errorClass })
|
|
15
|
+
* Accept Buffer | Uint8Array | string → returns Buffer. Throws
|
|
16
|
+
* errorClass on type mismatch or oversize.
|
|
17
|
+
*
|
|
18
|
+
* safeBuffer.boundedChunkCollector({ maxBytes, errorClass })
|
|
19
|
+
* Returns { push(chunk), result(), bytesCollected() }. Each push()
|
|
20
|
+
* enforces the cap on every chunk — the OOM defense for unbounded
|
|
21
|
+
* HTTP response bodies replacing the previous `chunks.push(c)` +
|
|
22
|
+
* `Buffer.concat(chunks)` pattern that accumulated arbitrary bytes
|
|
23
|
+
* before checking size.
|
|
24
|
+
*
|
|
25
|
+
* safeBuffer.secureZero(buf)
|
|
26
|
+
* Best-effort zero of buf contents (`buf.fill(0)`). JavaScript can't
|
|
27
|
+
* truly zero memory — V8 may have copies — but `fill(0)` removes the
|
|
28
|
+
* in-Buffer reference so a heap-dump won't show the secret in this
|
|
29
|
+
* particular Buffer. No-op on non-Buffers.
|
|
30
|
+
*
|
|
31
|
+
* Why a default error class:
|
|
32
|
+
* Each caller (xml-safe, json-safe, atomic-file, ...) wants to throw
|
|
33
|
+
* its own format-specific error class with a particular `code`. The
|
|
34
|
+
* helpers accept `{ errorClass }` so the byte-handling lives here but
|
|
35
|
+
* the error type stays format-aware (existing tests check
|
|
36
|
+
* e.code === "xml/too-large" etc.). A default SafeBufferError is used
|
|
37
|
+
* if the caller doesn't pass one.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
var { FrameworkError } = require("./framework-error");
|
|
41
|
+
|
|
42
|
+
class SafeBufferError extends FrameworkError {
|
|
43
|
+
constructor(message, code) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "SafeBufferError";
|
|
46
|
+
this.code = code || "buffer/invalid";
|
|
47
|
+
this.isSafeBufferError = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _throw(errorClass, message, code) {
|
|
52
|
+
var Cls = errorClass || SafeBufferError;
|
|
53
|
+
throw new Cls(message, code);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- normalizeText ----
|
|
57
|
+
|
|
58
|
+
function normalizeText(input, opts) {
|
|
59
|
+
opts = opts || {};
|
|
60
|
+
var maxBytes = (typeof opts.maxBytes === "number" && opts.maxBytes > 0) ? opts.maxBytes : null;
|
|
61
|
+
var stripBom = opts.stripBom !== false; // default true
|
|
62
|
+
var errClass = opts.errorClass;
|
|
63
|
+
var typeCode = opts.typeCode || "buffer/wrong-input-type";
|
|
64
|
+
var sizeCode = opts.sizeCode || "buffer/too-large";
|
|
65
|
+
var typeMsg = opts.typeMessage || "input must be string, Buffer, or Uint8Array";
|
|
66
|
+
var sizeMsg = opts.sizeMessage || "input exceeds maxBytes";
|
|
67
|
+
|
|
68
|
+
var text;
|
|
69
|
+
if (typeof input === "string") text = input;
|
|
70
|
+
else if (Buffer.isBuffer(input)) text = input.toString("utf8");
|
|
71
|
+
else if (input instanceof Uint8Array) text = Buffer.from(input).toString("utf8");
|
|
72
|
+
else _throw(errClass, typeMsg, typeCode);
|
|
73
|
+
|
|
74
|
+
if (stripBom && text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
|
|
75
|
+
|
|
76
|
+
if (maxBytes !== null && Buffer.byteLength(text, "utf8") > maxBytes) {
|
|
77
|
+
_throw(errClass, sizeMsg, sizeCode);
|
|
78
|
+
}
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- toBuffer ----
|
|
83
|
+
|
|
84
|
+
function toBuffer(data, opts) {
|
|
85
|
+
opts = opts || {};
|
|
86
|
+
var maxBytes = (typeof opts.maxBytes === "number" && opts.maxBytes > 0) ? opts.maxBytes : null;
|
|
87
|
+
var errClass = opts.errorClass;
|
|
88
|
+
var typeCode = opts.typeCode || "buffer/wrong-input-type";
|
|
89
|
+
var sizeCode = opts.sizeCode || "buffer/too-large";
|
|
90
|
+
var typeMsg = opts.typeMessage || "data must be Buffer, Uint8Array, or string";
|
|
91
|
+
var sizeMsg = opts.sizeMessage || "data exceeds maxBytes";
|
|
92
|
+
|
|
93
|
+
var buf;
|
|
94
|
+
if (Buffer.isBuffer(data)) buf = data;
|
|
95
|
+
else if (typeof data === "string") buf = Buffer.from(data, "utf8");
|
|
96
|
+
else if (data instanceof Uint8Array) buf = Buffer.from(data);
|
|
97
|
+
else _throw(errClass, typeMsg, typeCode);
|
|
98
|
+
|
|
99
|
+
if (maxBytes !== null && buf.length > maxBytes) {
|
|
100
|
+
_throw(errClass, sizeMsg, sizeCode);
|
|
101
|
+
}
|
|
102
|
+
return buf;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- boundedChunkCollector ----
|
|
106
|
+
//
|
|
107
|
+
// Replaces the unbounded `chunks.push(c); ... Buffer.concat(chunks)`
|
|
108
|
+
// pattern in HTTP response handlers. The cap is enforced at push() time
|
|
109
|
+
// so a 10-GB response from a hostile/misbehaving upstream rejects on the
|
|
110
|
+
// chunk that overflows — without first accumulating the whole 10 GB in
|
|
111
|
+
// the chunks array.
|
|
112
|
+
|
|
113
|
+
function boundedChunkCollector(opts) {
|
|
114
|
+
opts = opts || {};
|
|
115
|
+
var maxBytes = (typeof opts.maxBytes === "number" && opts.maxBytes > 0) ? opts.maxBytes : null;
|
|
116
|
+
if (maxBytes === null) {
|
|
117
|
+
throw new SafeBufferError("boundedChunkCollector requires maxBytes", "buffer/bad-arg");
|
|
118
|
+
}
|
|
119
|
+
var errClass = opts.errorClass;
|
|
120
|
+
var sizeCode = opts.sizeCode || "buffer/too-large";
|
|
121
|
+
var sizeMsg = opts.sizeMessage || "stream body exceeds maxBytes";
|
|
122
|
+
|
|
123
|
+
var chunks = [];
|
|
124
|
+
var total = 0;
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
push: function (chunk) {
|
|
128
|
+
// Accept Buffer or Uint8Array (Node's res.on('data') yields Buffer
|
|
129
|
+
// by default but consumers may have set encoding to get strings).
|
|
130
|
+
if (typeof chunk === "string") chunk = Buffer.from(chunk, "utf8");
|
|
131
|
+
else if (!Buffer.isBuffer(chunk) && chunk instanceof Uint8Array) chunk = Buffer.from(chunk);
|
|
132
|
+
if (!Buffer.isBuffer(chunk)) {
|
|
133
|
+
_throw(errClass, "chunk must be Buffer, Uint8Array, or string", "buffer/wrong-input-type");
|
|
134
|
+
}
|
|
135
|
+
if (total + chunk.length > maxBytes) {
|
|
136
|
+
_throw(errClass, sizeMsg, sizeCode);
|
|
137
|
+
}
|
|
138
|
+
chunks.push(chunk);
|
|
139
|
+
total += chunk.length;
|
|
140
|
+
},
|
|
141
|
+
result: function () {
|
|
142
|
+
return Buffer.concat(chunks, total);
|
|
143
|
+
},
|
|
144
|
+
bytesCollected: function () { return total; },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- secureZero ----
|
|
149
|
+
|
|
150
|
+
function secureZero(buf) {
|
|
151
|
+
if (Buffer.isBuffer(buf) || buf instanceof Uint8Array) {
|
|
152
|
+
try { buf.fill(0); } catch (_e) { /* best effort — locked memory etc. */ }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
normalizeText: normalizeText,
|
|
158
|
+
toBuffer: toBuffer,
|
|
159
|
+
boundedChunkCollector: boundedChunkCollector,
|
|
160
|
+
secureZero: secureZero,
|
|
161
|
+
SafeBufferError: SafeBufferError,
|
|
162
|
+
};
|