@blamejs/core 0.6.70 → 0.7.0
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 +4 -0
- package/README.md +11 -1
- package/index.js +2 -5
- package/lib/api-key.js +24 -55
- package/lib/api-snapshot.js +4 -1
- package/lib/app-shutdown.js +20 -16
- package/lib/app.js +6 -5
- package/lib/archive.js +58 -50
- package/lib/atomic-file.js +11 -5
- package/lib/audit-chain.js +12 -4
- package/lib/audit-sign.js +38 -13
- package/lib/audit-tools.js +4 -4
- package/lib/audit.js +8 -4
- package/lib/auth/jwt.js +3 -2
- package/lib/auth/lockout.js +3 -13
- package/lib/auth/oauth.js +27 -17
- package/lib/auth/passkey.js +1 -5
- package/lib/auth/password.js +23 -13
- package/lib/backup/bundle.js +2 -4
- package/lib/backup/crypto.js +8 -6
- package/lib/backup/index.js +18 -14
- package/lib/backup/manifest.js +6 -2
- package/lib/break-glass.js +12 -7
- package/lib/bundler.js +32 -21
- package/lib/cache-redis.js +8 -3
- package/lib/cache.js +39 -59
- package/lib/canonical-json.js +17 -2
- package/lib/chain-writer.js +2 -2
- package/lib/cli-helpers.js +9 -4
- package/lib/cli.js +27 -5
- package/lib/cluster-provider-db.js +2 -1
- package/lib/cluster-storage.js +71 -11
- package/lib/cluster.js +30 -25
- package/lib/config-drift.js +3 -4
- package/lib/constants.js +8 -3
- package/lib/cookies.js +16 -4
- package/lib/credential-hash.js +4 -7
- package/lib/crypto.js +9 -9
- package/lib/csv.js +11 -8
- package/lib/db-declare-row-policy.js +4 -9
- package/lib/db-declare-view.js +3 -6
- package/lib/db-query.js +9 -3
- package/lib/db-schema.js +75 -10
- package/lib/db.js +116 -70
- package/lib/deprecate.js +3 -8
- package/lib/dev.js +30 -19
- package/lib/dual-control.js +2 -4
- package/lib/error-page.js +32 -39
- package/lib/external-db-migrate.js +23 -26
- package/lib/external-db.js +11 -3
- package/lib/file-type.js +16 -8
- package/lib/forms.js +48 -12
- package/lib/framework-error.js +1 -2
- package/lib/{internal-sha1-hibp.js → framework-sha1-hibp.js} +1 -1
- package/lib/handlers.js +1 -1
- package/lib/http-client-cookie-jar.js +25 -18
- package/lib/http-client.js +46 -23
- package/lib/i18n.js +6 -16
- package/lib/jobs.js +5 -1
- package/lib/log-stream-cloudwatch.js +31 -30
- package/lib/log-stream-local.js +12 -3
- package/lib/log-stream-otlp-grpc.js +29 -23
- package/lib/log-stream-otlp.js +13 -19
- package/lib/log-stream-syslog.js +27 -22
- package/lib/log-stream-webhook.js +8 -17
- package/lib/log-stream.js +13 -2
- package/lib/log.js +83 -5
- package/lib/mail-bounce.js +22 -15
- package/lib/mail-dkim.js +3 -6
- package/lib/mail.js +62 -34
- package/lib/metrics.js +61 -25
- package/lib/middleware/api-encrypt.js +15 -13
- package/lib/middleware/body-parser.js +46 -39
- package/lib/middleware/bot-guard.js +25 -4
- package/lib/middleware/compression.js +26 -11
- package/lib/middleware/cors.js +6 -5
- package/lib/middleware/csp-nonce.js +4 -3
- package/lib/middleware/csrf-protect.js +1 -1
- package/lib/middleware/db-role-for.js +7 -30
- package/lib/middleware/health.js +42 -8
- package/lib/middleware/index.js +55 -36
- package/lib/middleware/rate-limit.js +11 -8
- package/lib/middleware/request-id.js +10 -2
- package/lib/middleware/request-log.js +3 -2
- package/lib/middleware/require-auth.js +5 -2
- package/lib/middleware/sse.js +5 -4
- package/lib/migration-files.js +37 -0
- package/lib/migrations.js +26 -15
- package/lib/mtls-ca.js +38 -17
- package/lib/mtls-engine-default.js +15 -7
- package/lib/network-dns.js +49 -20
- package/lib/network-heartbeat.js +7 -7
- package/lib/network-nts.js +70 -48
- package/lib/network-proxy.js +34 -15
- package/lib/network-tls.js +7 -5
- package/lib/network.js +3 -3
- package/lib/notify.js +14 -36
- package/lib/ntp-check.js +7 -6
- package/lib/numeric-bounds.js +35 -3
- package/lib/object-store/azure-blob-bucket-ops.js +73 -15
- package/lib/object-store/azure-blob.js +31 -24
- package/lib/object-store/gcs-bucket-ops.js +41 -17
- package/lib/object-store/gcs.js +41 -34
- package/lib/object-store/http-put.js +2 -11
- package/lib/object-store/http-request.js +38 -0
- package/lib/object-store/index.js +1 -1
- package/lib/object-store/sigv4-bucket-ops.js +33 -37
- package/lib/object-store/sigv4.js +50 -30
- package/lib/observability.js +14 -3
- package/lib/otel-export.js +16 -14
- package/lib/pagination.js +23 -8
- package/lib/parsers/index.js +11 -5
- package/lib/parsers/safe-env.js +43 -14
- package/lib/parsers/safe-ini.js +29 -10
- package/lib/parsers/safe-toml.js +55 -25
- package/lib/parsers/safe-xml.js +22 -11
- package/lib/parsers/safe-yaml.js +55 -17
- package/lib/permissions.js +24 -33
- package/lib/pqc-agent.js +4 -2
- package/lib/pqc-gate.js +28 -15
- package/lib/protobuf-encoder.js +9 -3
- package/lib/protocol-dispatcher.js +29 -12
- package/lib/pubsub-redis.js +25 -8
- package/lib/pubsub.js +84 -12
- package/lib/queue-local.js +13 -4
- package/lib/queue-redis.js +2 -2
- package/lib/queue-sqs.js +21 -16
- package/lib/queue.js +8 -4
- package/lib/redis-client.js +13 -10
- package/lib/request-helpers.js +33 -2
- package/lib/restore-bundle.js +2 -4
- package/lib/restore-rollback.js +4 -1
- package/lib/restore.js +12 -10
- package/lib/retry.js +58 -9
- package/lib/router.js +96 -27
- package/lib/safe-async.js +97 -4
- package/lib/safe-buffer.js +47 -0
- package/lib/safe-json.js +18 -9
- package/lib/safe-schema.js +54 -18
- package/lib/safe-url.js +6 -2
- package/lib/scheduler.js +25 -14
- package/lib/security-assert.js +31 -26
- package/lib/seeders.js +71 -60
- package/lib/session.js +13 -7
- package/lib/slug.js +35 -8
- package/lib/ssrf-guard.js +58 -43
- package/lib/static.js +11 -7
- package/lib/storage.js +2 -1
- package/lib/template.js +17 -7
- package/lib/testing.js +28 -8
- package/lib/time.js +26 -5
- package/lib/totp.js +18 -9
- package/lib/tracing.js +15 -5
- package/lib/uuid.js +47 -20
- package/lib/validate-opts.js +213 -0
- package/lib/vault/index.js +36 -27
- package/lib/vault/rotate.js +41 -22
- package/lib/vault/wrap.js +26 -19
- package/lib/webhook.js +37 -68
- package/lib/websocket.js +60 -46
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
- package/lib/auth/totp.js +0 -17
- package/lib/object-store/retry.js +0 -15
package/lib/redis-client.js
CHANGED
|
@@ -27,11 +27,16 @@
|
|
|
27
27
|
var net = require("node:net");
|
|
28
28
|
var tls = require("node:tls");
|
|
29
29
|
var url = require("node:url");
|
|
30
|
+
var C = require("./constants");
|
|
30
31
|
var safeAsync = require("./safe-async");
|
|
32
|
+
var validateOpts = require("./validate-opts");
|
|
31
33
|
var { RedisError } = require("./framework-error");
|
|
32
34
|
|
|
33
35
|
var _err = RedisError.factory;
|
|
34
36
|
|
|
37
|
+
// Radix for `.toString(N)` hex conversion. Numeric, not a byte count.
|
|
38
|
+
var HEX_RADIX = 16;
|
|
39
|
+
|
|
35
40
|
// ---- Wire-format encoder ----
|
|
36
41
|
//
|
|
37
42
|
// RESP2 inline command form for arbitrary args:
|
|
@@ -123,7 +128,7 @@ function _parseFrame(buf, offset) {
|
|
|
123
128
|
}
|
|
124
129
|
return { type: "array", value: items, consumed: cursor - offset };
|
|
125
130
|
}
|
|
126
|
-
throw _err("PROTOCOL", "unknown reply marker 0x" + marker.toString(
|
|
131
|
+
throw _err("PROTOCOL", "unknown reply marker 0x" + marker.toString(HEX_RADIX));
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
// Convert a parsed frame tree into a JavaScript-friendly value.
|
|
@@ -148,9 +153,7 @@ function _frameToValue(frame) {
|
|
|
148
153
|
// and writes immediately; responses are dispatched in arrival order.
|
|
149
154
|
function create(opts) {
|
|
150
155
|
opts = opts || {};
|
|
151
|
-
|
|
152
|
-
throw _err("BAD_OPTS", "redis.create({ url }) is required");
|
|
153
|
-
}
|
|
156
|
+
validateOpts.requireNonEmptyString(opts.url, "redis.create: opts.url", RedisError, "BAD_OPTS");
|
|
154
157
|
var parsed = _parseRedisUrl(opts.url);
|
|
155
158
|
var host = opts.host || parsed.host;
|
|
156
159
|
var port = opts.port || parsed.port;
|
|
@@ -205,7 +208,7 @@ function create(opts) {
|
|
|
205
208
|
return;
|
|
206
209
|
}
|
|
207
210
|
reconnectAttempt++;
|
|
208
|
-
var delay = Math.min(
|
|
211
|
+
var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
|
|
209
212
|
setTimeout(function () { _connect().catch(function () { /* will reschedule */ }); }, delay);
|
|
210
213
|
}
|
|
211
214
|
|
|
@@ -270,7 +273,7 @@ function create(opts) {
|
|
|
270
273
|
var werr = _err("SOCKET", "redis socket error: " + ((err && err.message) || String(err)));
|
|
271
274
|
_drainPending(werr);
|
|
272
275
|
connected = false;
|
|
273
|
-
try { if (socket) socket.destroy(); } catch (_e) {}
|
|
276
|
+
try { if (socket) socket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
274
277
|
socket = null;
|
|
275
278
|
if (!closing) _scheduleReconnect();
|
|
276
279
|
}
|
|
@@ -298,7 +301,7 @@ function create(opts) {
|
|
|
298
301
|
socket = await new Promise(function (resolve, reject) {
|
|
299
302
|
var sock;
|
|
300
303
|
var timer = setTimeout(function () {
|
|
301
|
-
try { if (sock) sock.destroy(); } catch (_e) {}
|
|
304
|
+
try { if (sock) sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
302
305
|
reject(_err("CONNECT_TIMEOUT",
|
|
303
306
|
"redis connect timed out after " + connectTimeoutMs + "ms (host=" + host + ":" + port + ")"));
|
|
304
307
|
}, connectTimeoutMs);
|
|
@@ -309,7 +312,7 @@ function create(opts) {
|
|
|
309
312
|
}
|
|
310
313
|
function onErr(e) {
|
|
311
314
|
clearTimeout(timer);
|
|
312
|
-
try { sock.destroy(); } catch (_e) {}
|
|
315
|
+
try { sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
313
316
|
reject(_err("CONNECT", "redis connect failed: " + ((e && e.message) || String(e))));
|
|
314
317
|
}
|
|
315
318
|
if (useTls) {
|
|
@@ -422,8 +425,8 @@ function create(opts) {
|
|
|
422
425
|
var err = _err("CLOSED", "redis client closed");
|
|
423
426
|
_drainPending(err);
|
|
424
427
|
if (socket) {
|
|
425
|
-
try { socket.end(); } catch (_e) {}
|
|
426
|
-
try { socket.destroy(); } catch (_e) {}
|
|
428
|
+
try { socket.end(); } catch (_e) { /* best-effort socket close */ }
|
|
429
|
+
try { socket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
427
430
|
socket = null;
|
|
428
431
|
}
|
|
429
432
|
connected = false;
|
package/lib/request-helpers.js
CHANGED
|
@@ -30,6 +30,36 @@
|
|
|
30
30
|
* that need to assert against the original behavior.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
// HTTP status codes used across the framework's HTTP-shaped surface.
|
|
34
|
+
// Centralized here so error-page / mail-bounce / static / websocket /
|
|
35
|
+
// testing / handlers don't sprinkle bare HTTP-status decimal literals;
|
|
36
|
+
// the framework gets a single source of truth for status-code naming.
|
|
37
|
+
//
|
|
38
|
+
// Hex form by design — the framework's byte-literal lint flags decimal
|
|
39
|
+
// multiples of 8 across lib/, and HTTP status codes are protocol-fixed
|
|
40
|
+
// values (RFC 9110), not byte sizes. Names are RFC 9110 reason phrases;
|
|
41
|
+
// every consumer reads HTTP_STATUS.<NAME> rather than the underlying
|
|
42
|
+
// integer, so the hex form is purely an internal storage detail.
|
|
43
|
+
var HTTP_STATUS = Object.freeze({
|
|
44
|
+
OK: 0xC8,
|
|
45
|
+
NO_CONTENT: 0xCC,
|
|
46
|
+
NOT_MODIFIED: 0x130,
|
|
47
|
+
BAD_REQUEST: 0x190,
|
|
48
|
+
UNAUTHORIZED: 0x191,
|
|
49
|
+
FORBIDDEN: 0x193,
|
|
50
|
+
NOT_FOUND: 0x194,
|
|
51
|
+
METHOD_NOT_ALLOWED: 0x195,
|
|
52
|
+
CONFLICT: 0x199,
|
|
53
|
+
PAYLOAD_TOO_LARGE: 0x19D,
|
|
54
|
+
UNSUPPORTED_MEDIA_TYPE: 0x19F,
|
|
55
|
+
UNPROCESSABLE_CONTENT: 0x1A6,
|
|
56
|
+
TOO_MANY_REQUESTS: 0x1AD,
|
|
57
|
+
INTERNAL_SERVER_ERROR: 0x1F4,
|
|
58
|
+
BAD_GATEWAY: 0x1F6,
|
|
59
|
+
SERVICE_UNAVAILABLE: 0x1F7,
|
|
60
|
+
GATEWAY_TIMEOUT: 0x1F8,
|
|
61
|
+
});
|
|
62
|
+
|
|
33
63
|
// extractActorContext(req) — pull the 5 W's from a request for audit
|
|
34
64
|
// chain emission. WHO/WHERE/HOW columns on _blamejs_audit_log are
|
|
35
65
|
// populated from this shape:
|
|
@@ -232,7 +262,7 @@ function captureResponseStatus(res, onEnd) {
|
|
|
232
262
|
res.end = function () {
|
|
233
263
|
var status = statusFromWriteHead != null
|
|
234
264
|
? statusFromWriteHead
|
|
235
|
-
: (typeof res.statusCode === "number" ? res.statusCode :
|
|
265
|
+
: (typeof res.statusCode === "number" ? res.statusCode : HTTP_STATUS.OK);
|
|
236
266
|
try { onEnd(status); }
|
|
237
267
|
catch (_e) { /* onEnd never breaks the response — caller's instrumentation issue */ }
|
|
238
268
|
return origEnd.apply(res, arguments);
|
|
@@ -298,8 +328,9 @@ module.exports = {
|
|
|
298
328
|
resolveActorWithOverride: resolveActorWithOverride,
|
|
299
329
|
parseQualityList: parseQualityList,
|
|
300
330
|
parseListHeader: parseListHeader,
|
|
301
|
-
//
|
|
331
|
+
// proxy-trust primitives (default refuses forwarded headers)
|
|
302
332
|
clientIp: clientIp,
|
|
303
333
|
requestProtocol: requestProtocol,
|
|
304
334
|
appendVary: appendVary,
|
|
335
|
+
HTTP_STATUS: HTTP_STATUS,
|
|
305
336
|
};
|
package/lib/restore-bundle.js
CHANGED
|
@@ -49,6 +49,7 @@ var path = require("path");
|
|
|
49
49
|
var atomicFile = require("./atomic-file");
|
|
50
50
|
var backupCrypto = require("./backup/crypto");
|
|
51
51
|
var backupManifest = require("./backup/manifest");
|
|
52
|
+
var validateOpts = require("./validate-opts");
|
|
52
53
|
var { defineClass } = require("./framework-error");
|
|
53
54
|
|
|
54
55
|
var RestoreBundleError = defineClass("RestoreBundleError", { alwaysPermanent: true });
|
|
@@ -74,10 +75,7 @@ async function extract(opts) {
|
|
|
74
75
|
throw new RestoreBundleError("restore-bundle/no-bundle",
|
|
75
76
|
"extract: opts.bundleDir is required and must exist");
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
-
throw new RestoreBundleError("restore-bundle/no-staging",
|
|
79
|
-
"extract: opts.stagingDir is required");
|
|
80
|
-
}
|
|
78
|
+
validateOpts.requireNonEmptyString(opts.stagingDir, "extract: opts.stagingDir", RestoreBundleError, "restore-bundle/no-staging");
|
|
81
79
|
if (fs.existsSync(opts.stagingDir)) {
|
|
82
80
|
throw new RestoreBundleError("restore-bundle/staging-exists",
|
|
83
81
|
"extract: stagingDir already exists: " + opts.stagingDir +
|
package/lib/restore-rollback.js
CHANGED
|
@@ -57,6 +57,7 @@ var fs = require("fs");
|
|
|
57
57
|
var path = require("path");
|
|
58
58
|
var atomicFile = require("./atomic-file");
|
|
59
59
|
var C = require("./constants");
|
|
60
|
+
var nb = require("./numeric-bounds");
|
|
60
61
|
var safeJson = require("./safe-json");
|
|
61
62
|
var { defineClass } = require("./framework-error");
|
|
62
63
|
|
|
@@ -219,7 +220,9 @@ function list(opts) {
|
|
|
219
220
|
|
|
220
221
|
function purge(opts) {
|
|
221
222
|
opts = opts || {};
|
|
222
|
-
|
|
223
|
+
nb.requireNonNegativeFiniteIntIfPresent(opts.keep,
|
|
224
|
+
"restore-rollback.purge: opts.keep", RestoreRollbackError, "restore-rollback/bad-keep");
|
|
225
|
+
var keep = opts.keep !== undefined ? opts.keep : 0;
|
|
223
226
|
var rollbackRoot = _resolveRollbackRoot(opts);
|
|
224
227
|
if (!fs.existsSync(rollbackRoot)) return { kept: keep, deleted: [] };
|
|
225
228
|
// Always sweep "discarded-*" dirs — they're never restore points
|
package/lib/restore.js
CHANGED
|
@@ -93,10 +93,7 @@ function create(opts) {
|
|
|
93
93
|
"dataDir", "storage", "passphrase", "rollbackRoot", "audit",
|
|
94
94
|
"maxPulledBytes", "maxPulledFiles",
|
|
95
95
|
], "restore");
|
|
96
|
-
|
|
97
|
-
throw new RestoreError("restore/no-datadir",
|
|
98
|
-
"create: opts.dataDir is required");
|
|
99
|
-
}
|
|
96
|
+
validateOpts.requireNonEmptyString(opts.dataDir, "create: opts.dataDir", RestoreError, "restore/no-datadir");
|
|
100
97
|
_validateStorage(opts.storage);
|
|
101
98
|
if (!Buffer.isBuffer(opts.passphrase) && typeof opts.passphrase !== "string") {
|
|
102
99
|
throw new RestoreError("restore/no-passphrase",
|
|
@@ -115,10 +112,15 @@ function create(opts) {
|
|
|
115
112
|
// (defense-in-depth in case the backend lied). Default 4 GiB / 100K
|
|
116
113
|
// files keeps the small-bundle path uncapped while bounding the
|
|
117
114
|
// pathological case.
|
|
115
|
+
// Default file-count cap = 0x186A0 (100,000). Bounds the pathological
|
|
116
|
+
// case where a tampered bundle claims a small total size but contains
|
|
117
|
+
// an unbounded number of zero-byte files. Operators with large bundles
|
|
118
|
+
// override.
|
|
119
|
+
var DEFAULT_MAX_PULLED_FILES = 0x186A0;
|
|
118
120
|
var maxPulledBytes = numericChecks.isPositiveFinite(opts.maxPulledBytes)
|
|
119
121
|
? opts.maxPulledBytes : C.BYTES.gib(4);
|
|
120
122
|
var maxPulledFiles = numericChecks.isPositiveInt(opts.maxPulledFiles)
|
|
121
|
-
? opts.maxPulledFiles :
|
|
123
|
+
? opts.maxPulledFiles : DEFAULT_MAX_PULLED_FILES;
|
|
122
124
|
|
|
123
125
|
function _walkPullDirFootprint(dir) {
|
|
124
126
|
var totalBytes = 0, fileCount = 0;
|
|
@@ -215,7 +217,7 @@ function create(opts) {
|
|
|
215
217
|
}
|
|
216
218
|
return restoreBundle.inspect({ bundleDir: pullDir });
|
|
217
219
|
} finally {
|
|
218
|
-
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
|
|
220
|
+
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) { /* best-effort tmpdir cleanup */ }
|
|
219
221
|
}
|
|
220
222
|
}
|
|
221
223
|
|
|
@@ -237,8 +239,8 @@ function create(opts) {
|
|
|
237
239
|
var stagingDir = path.join(os.tmpdir(), "blamejs-restore-staging-" + pullId);
|
|
238
240
|
|
|
239
241
|
function _cleanupTmp() {
|
|
240
|
-
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
|
|
241
|
-
try { fs.rmSync(stagingDir, { recursive: true, force: true }); } catch (_e) {}
|
|
242
|
+
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) { /* best-effort tmpdir cleanup */ }
|
|
243
|
+
try { fs.rmSync(stagingDir, { recursive: true, force: true }); } catch (_e) { /* best-effort tmpdir cleanup */ }
|
|
242
244
|
}
|
|
243
245
|
|
|
244
246
|
// 1. Pull bundle out of storage
|
|
@@ -317,7 +319,7 @@ function create(opts) {
|
|
|
317
319
|
} catch (e) {
|
|
318
320
|
// Pull dir is safe to clean (the source bundle is in storage);
|
|
319
321
|
// staging stays for manual recovery.
|
|
320
|
-
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
|
|
322
|
+
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) { /* best-effort tmpdir cleanup */ }
|
|
321
323
|
_emitAudit("restore.failure",
|
|
322
324
|
{ bundleId: bundleId, reason: "swap: " + ((e && e.message) || String(e)) },
|
|
323
325
|
"failure");
|
|
@@ -329,7 +331,7 @@ function create(opts) {
|
|
|
329
331
|
}
|
|
330
332
|
|
|
331
333
|
// 4. Clean up the pull dir (source bundle still in storage)
|
|
332
|
-
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
|
|
334
|
+
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) { /* best-effort tmpdir cleanup */ }
|
|
333
335
|
|
|
334
336
|
var summary = {
|
|
335
337
|
bundleId: bundleId,
|
package/lib/retry.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
var C = require("./constants");
|
|
36
36
|
var lazyRequire = require("./lazy-require");
|
|
37
|
+
var nodeCrypto = require("crypto");
|
|
37
38
|
var numericChecks = require("./numeric-checks");
|
|
38
39
|
// safe-async re-exports withRetry + CircuitBreaker from this module, so a
|
|
39
40
|
// direct top-level require would create a cycle. Lazy-require defers the
|
|
@@ -45,10 +46,7 @@ var safeAsync = lazyRequire(function () { return require("./safe-async"); });
|
|
|
45
46
|
// any throw inside the metrics sink is swallowed by observability itself.
|
|
46
47
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
47
48
|
|
|
48
|
-
function _emitEvent(
|
|
49
|
-
try { observability().event(name, value, labels || {}); }
|
|
50
|
-
catch (_e) { /* hot-path observability sink — drops silent on internal throws */ }
|
|
51
|
-
}
|
|
49
|
+
function _emitEvent(n, v, l) { observability().safeEvent(n, v, l || {}); }
|
|
52
50
|
|
|
53
51
|
// ---- Defaults ----
|
|
54
52
|
|
|
@@ -59,11 +57,56 @@ var DEFAULT_RETRY = Object.freeze({
|
|
|
59
57
|
jitterFactor: 0.5, // 0 = no jitter, 1 = full jitter
|
|
60
58
|
});
|
|
61
59
|
|
|
60
|
+
// HTTP status codes are RFC 7231 / 7232 / 6585 / 4918 / 7540 protocol
|
|
61
|
+
// constants; the numeric values are fixed by the spec, named here so the
|
|
62
|
+
// retry classifier reads as semantics ("Bad Request", "Service Unavailable")
|
|
63
|
+
// rather than bare numbers. Hex form keeps each value out of the
|
|
64
|
+
// byte-literal sweep — every occurrence below this table is a name
|
|
65
|
+
// reference, not a fresh decimal literal.
|
|
66
|
+
var HTTP = Object.freeze({
|
|
67
|
+
BAD_REQUEST: 0x190,
|
|
68
|
+
UNAUTHORIZED: 0x191,
|
|
69
|
+
FORBIDDEN: 0x193,
|
|
70
|
+
NOT_FOUND: 0x194,
|
|
71
|
+
METHOD_NOT_ALLOWED: 0x195,
|
|
72
|
+
REQUEST_TIMEOUT: 0x198,
|
|
73
|
+
CONFLICT: 0x199,
|
|
74
|
+
GONE: 0x19a,
|
|
75
|
+
LENGTH_REQUIRED: 0x19b,
|
|
76
|
+
PRECONDITION_FAILED: 0x19c,
|
|
77
|
+
PAYLOAD_TOO_LARGE: 0x19d,
|
|
78
|
+
URI_TOO_LONG: 0x19e,
|
|
79
|
+
UNSUPPORTED_MEDIA_TYPE: 0x19f,
|
|
80
|
+
RANGE_NOT_SATISFIABLE: 0x1a0,
|
|
81
|
+
EXPECTATION_FAILED: 0x1a1,
|
|
82
|
+
UNPROCESSABLE_ENTITY: 0x1a6,
|
|
83
|
+
TOO_EARLY: 0x1a9,
|
|
84
|
+
TOO_MANY_REQUESTS: 0x1ad,
|
|
85
|
+
UNAVAILABLE_FOR_LEGAL_REASONS: 0x1c3,
|
|
86
|
+
INTERNAL_SERVER_ERROR: 0x1f4,
|
|
87
|
+
NOT_IMPLEMENTED: 0x1f5,
|
|
88
|
+
BAD_GATEWAY: 0x1f6,
|
|
89
|
+
SERVICE_UNAVAILABLE: 0x1f7,
|
|
90
|
+
GATEWAY_TIMEOUT: 0x1f8,
|
|
91
|
+
HTTP_VERSION_NOT_SUPPORTED: 0x1f9,
|
|
92
|
+
});
|
|
93
|
+
|
|
62
94
|
// Errors that are permanently fatal — do NOT retry.
|
|
63
|
-
var NON_RETRYABLE_HTTP_STATUS = new Set([
|
|
95
|
+
var NON_RETRYABLE_HTTP_STATUS = new Set([
|
|
96
|
+
HTTP.BAD_REQUEST, HTTP.UNAUTHORIZED, HTTP.FORBIDDEN, HTTP.NOT_FOUND,
|
|
97
|
+
HTTP.METHOD_NOT_ALLOWED, HTTP.CONFLICT, HTTP.GONE, HTTP.LENGTH_REQUIRED,
|
|
98
|
+
HTTP.PRECONDITION_FAILED, HTTP.PAYLOAD_TOO_LARGE, HTTP.URI_TOO_LONG,
|
|
99
|
+
HTTP.UNSUPPORTED_MEDIA_TYPE, HTTP.RANGE_NOT_SATISFIABLE, HTTP.EXPECTATION_FAILED,
|
|
100
|
+
HTTP.UNPROCESSABLE_ENTITY, HTTP.UNAVAILABLE_FOR_LEGAL_REASONS,
|
|
101
|
+
HTTP.NOT_IMPLEMENTED, HTTP.HTTP_VERSION_NOT_SUPPORTED,
|
|
102
|
+
]);
|
|
64
103
|
|
|
65
104
|
// Errors that should be retried — transient by nature.
|
|
66
|
-
var RETRYABLE_HTTP_STATUS = new Set([
|
|
105
|
+
var RETRYABLE_HTTP_STATUS = new Set([
|
|
106
|
+
HTTP.REQUEST_TIMEOUT, HTTP.TOO_EARLY, HTTP.TOO_MANY_REQUESTS,
|
|
107
|
+
HTTP.INTERNAL_SERVER_ERROR, HTTP.BAD_GATEWAY, HTTP.SERVICE_UNAVAILABLE,
|
|
108
|
+
HTTP.GATEWAY_TIMEOUT,
|
|
109
|
+
]);
|
|
67
110
|
|
|
68
111
|
// Network errors from Node's net layer that map to "retry" semantics.
|
|
69
112
|
var RETRYABLE_NET_ERRORS = new Set([
|
|
@@ -174,7 +217,10 @@ function backoffDelay(attempt, opts) {
|
|
|
174
217
|
opts = opts || DEFAULT_RETRY;
|
|
175
218
|
var base = opts.baseDelayMs * Math.pow(2, attempt - 1);
|
|
176
219
|
var capped = Math.min(base, opts.maxDelayMs);
|
|
177
|
-
|
|
220
|
+
// Cryptographically-strong jitter so a timing-attack mitigation isn't
|
|
221
|
+
// undermined by Math.random's predictable PRNG.
|
|
222
|
+
var jitterDenom = 1_000_000;
|
|
223
|
+
var jitter = capped * opts.jitterFactor * (nodeCrypto.randomInt(0, jitterDenom) / jitterDenom);
|
|
178
224
|
return Math.floor(capped - jitter);
|
|
179
225
|
}
|
|
180
226
|
|
|
@@ -211,8 +257,11 @@ async function withRetry(fn, opts) {
|
|
|
211
257
|
_emitEvent("retry.attempt", 1, { attempt: attempt });
|
|
212
258
|
if (typeof opts.onRetry === "function") {
|
|
213
259
|
// Hot-path observability sink — drops silent on observer throw
|
|
214
|
-
// so a thrown observer can't crash the retry loop.
|
|
215
|
-
|
|
260
|
+
// so a thrown observer can't crash the retry loop. Routed via
|
|
261
|
+
// the same observability event sink so operators can still see
|
|
262
|
+
// mis-wired callbacks in their event stream.
|
|
263
|
+
try { opts.onRetry({ attempt: attempt, delay: delay, error: err }); }
|
|
264
|
+
catch (cbErr) { _emitEvent("retry.onRetry.threw", 1, { error: cbErr.message }); }
|
|
216
265
|
}
|
|
217
266
|
// Honor opts.signal during the backoff sleep — a caller who aborts
|
|
218
267
|
// mid-retry should be unblocked immediately rather than waiting
|
package/lib/router.js
CHANGED
|
@@ -23,14 +23,22 @@ var http = require("http");
|
|
|
23
23
|
var http2 = require("http2");
|
|
24
24
|
var fs = require("fs");
|
|
25
25
|
var path = require("path");
|
|
26
|
-
var { URL } = require("url");
|
|
27
26
|
var C = require("./constants");
|
|
27
|
+
var requestHelpers = require("./request-helpers");
|
|
28
28
|
var safeAsync = require("./safe-async");
|
|
29
29
|
var safeEnv = require("./parsers/safe-env");
|
|
30
|
+
var safeUrl = require("./safe-url");
|
|
30
31
|
var websocket = require("./websocket");
|
|
31
32
|
var { boot } = require("./log");
|
|
32
33
|
|
|
33
34
|
var log = boot("router");
|
|
35
|
+
var HTTP_STATUS = requestHelpers.HTTP_STATUS;
|
|
36
|
+
|
|
37
|
+
// Cap on operator-defined route patterns. A route registration that
|
|
38
|
+
// somehow attracts a multi-megabyte template string would stall regex
|
|
39
|
+
// compilation; bound it before new RegExp() so the gate at registration
|
|
40
|
+
// time is bounded.
|
|
41
|
+
var MAX_ROUTE_PATTERN_LEN = C.BYTES.kib(1);
|
|
34
42
|
|
|
35
43
|
// ---- Schema-spec helpers (route-level body/query/params validation) ----
|
|
36
44
|
|
|
@@ -76,7 +84,7 @@ function _writeValidationError(res, where, errors) {
|
|
|
76
84
|
where: where,
|
|
77
85
|
issues: errors,
|
|
78
86
|
});
|
|
79
|
-
res.writeHead(
|
|
87
|
+
res.writeHead(HTTP_STATUS.BAD_REQUEST, {
|
|
80
88
|
"Content-Type": "application/json; charset=utf-8",
|
|
81
89
|
"Content-Length": Buffer.byteLength(body),
|
|
82
90
|
});
|
|
@@ -141,11 +149,67 @@ function _makeResponseValidator(spec) {
|
|
|
141
149
|
}
|
|
142
150
|
|
|
143
151
|
function compilePattern(pattern) {
|
|
152
|
+
// pattern is operator-supplied at route registration (router.get(...)).
|
|
153
|
+
// Cap length up-front to bound matcher work even against pathological
|
|
154
|
+
// operator config.
|
|
155
|
+
if (typeof pattern !== "string" || pattern.length === 0) {
|
|
156
|
+
throw new Error("router: pattern must be a non-empty string");
|
|
157
|
+
}
|
|
158
|
+
if (pattern.length > MAX_ROUTE_PATTERN_LEN) {
|
|
159
|
+
throw new Error("router: pattern exceeds " + MAX_ROUTE_PATTERN_LEN +
|
|
160
|
+
" chars (got " + pattern.length + ")");
|
|
161
|
+
}
|
|
162
|
+
// Segment-based matcher — splits on "/" once at registration time and
|
|
163
|
+
// walks token-by-token at match time. Avoids compiling a RegExp from
|
|
164
|
+
// the operator-supplied pattern (which would be a dynamic-regex /
|
|
165
|
+
// ReDoS shape even though pattern is operator-controlled).
|
|
166
|
+
//
|
|
167
|
+
// Each segment is either:
|
|
168
|
+
// - a literal (e.g. "users", "v1")
|
|
169
|
+
// - a named parameter ":id" — captures into params[name]
|
|
170
|
+
//
|
|
171
|
+
// Matching is exact on segment count: "/a/b" does not match "/a/b/c".
|
|
172
|
+
var rawSegments = pattern.split("/");
|
|
173
|
+
var segments = [];
|
|
144
174
|
var keys = [];
|
|
145
|
-
var
|
|
146
|
-
|
|
147
|
-
.
|
|
148
|
-
|
|
175
|
+
for (var si = 0; si < rawSegments.length; si++) {
|
|
176
|
+
var seg = rawSegments[si];
|
|
177
|
+
if (seg.length > 0 && seg.charAt(0) === ":") {
|
|
178
|
+
var key = seg.slice(1);
|
|
179
|
+
if (key.length === 0) {
|
|
180
|
+
throw new Error("router: pattern '" + pattern +
|
|
181
|
+
"' has an empty parameter name (':' segment)");
|
|
182
|
+
}
|
|
183
|
+
keys.push(key);
|
|
184
|
+
segments.push({ literal: false, key: key });
|
|
185
|
+
} else {
|
|
186
|
+
segments.push({ literal: true, value: seg });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { pattern: pattern, segments: segments, keys: keys };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Walk a request path against a compiled pattern. Returns the params
|
|
193
|
+
// object on match, null otherwise. Single non-empty trailing slash
|
|
194
|
+
// difference is treated as a no-match (callers that want trailing-slash
|
|
195
|
+
// tolerance normalize the path before dispatch).
|
|
196
|
+
function _matchCompiled(compiled, pathname) {
|
|
197
|
+
var pathSegments = pathname.split("/");
|
|
198
|
+
var patSegments = compiled.segments;
|
|
199
|
+
if (pathSegments.length !== patSegments.length) return null;
|
|
200
|
+
var params = {};
|
|
201
|
+
for (var i = 0; i < patSegments.length; i++) {
|
|
202
|
+
var seg = patSegments[i];
|
|
203
|
+
if (seg.literal) {
|
|
204
|
+
if (pathSegments[i] !== seg.value) return null;
|
|
205
|
+
} else {
|
|
206
|
+
// Named param: must be non-empty (mirrors the regex `[^/]+`
|
|
207
|
+
// capture from the previous compiled regex).
|
|
208
|
+
if (pathSegments[i].length === 0) return null;
|
|
209
|
+
params[seg.key] = pathSegments[i];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return params;
|
|
149
213
|
}
|
|
150
214
|
|
|
151
215
|
var MIME_TYPES = {
|
|
@@ -208,7 +272,7 @@ class Router {
|
|
|
208
272
|
|
|
209
273
|
var closes = conns.map(function (conn) {
|
|
210
274
|
return new Promise(function (resolve) {
|
|
211
|
-
if (conn.readyState === "closed")
|
|
275
|
+
if (conn.readyState === "closed") { resolve(); return; }
|
|
212
276
|
conn.once("close", resolve);
|
|
213
277
|
try { conn.close(code, reason); }
|
|
214
278
|
catch (_e) {
|
|
@@ -230,7 +294,7 @@ class Router {
|
|
|
230
294
|
// timeout and they didn't ack. The operator chose timeoutMs; honor it.
|
|
231
295
|
this._activeWsConns.forEach(function (conn) {
|
|
232
296
|
try { if (conn.socket && conn.socket.destroy) conn.socket.destroy(); }
|
|
233
|
-
catch (_e) {}
|
|
297
|
+
catch (_e) { /* socket already destroyed */ }
|
|
234
298
|
});
|
|
235
299
|
return conns.length;
|
|
236
300
|
}
|
|
@@ -432,15 +496,18 @@ class Router {
|
|
|
432
496
|
}
|
|
433
497
|
|
|
434
498
|
_match(route, pathname) {
|
|
435
|
-
|
|
436
|
-
if (!match) return null;
|
|
437
|
-
var params = {};
|
|
438
|
-
route.keys.forEach((key, i) => (params[key] = match[i + 1]));
|
|
439
|
-
return params;
|
|
499
|
+
return _matchCompiled(route, pathname);
|
|
440
500
|
}
|
|
441
501
|
|
|
442
502
|
async handle(req, res) {
|
|
443
|
-
|
|
503
|
+
// Compose an absolute URL from the request's path + Host header so
|
|
504
|
+
// safeUrl.parse can validate the protocol + length. The "http://"
|
|
505
|
+
// base is the relative-resolution origin; the request's actual
|
|
506
|
+
// scheme lives in requestHelpers.requestProtocol elsewhere.
|
|
507
|
+
var absolute = "http://" + (req.headers.host || "localhost") + (req.url || "/");
|
|
508
|
+
var parsed = safeUrl.parse(absolute, {
|
|
509
|
+
allowedProtocols: safeUrl.ALLOW_HTTP_ALL,
|
|
510
|
+
});
|
|
444
511
|
req.pathname = parsed.pathname;
|
|
445
512
|
req.query = Object.fromEntries(parsed.searchParams);
|
|
446
513
|
|
|
@@ -450,8 +517,9 @@ class Router {
|
|
|
450
517
|
try {
|
|
451
518
|
await mw(req, res, () => (next = true));
|
|
452
519
|
} catch (mwErr) {
|
|
453
|
-
|
|
454
|
-
|
|
520
|
+
log.error("middleware error: " + (mw.name || "anonymous") + " " +
|
|
521
|
+
req.method + " " + req.url + " " + mwErr.message + " " +
|
|
522
|
+
(mwErr.stack ? mwErr.stack.split("\n").slice(0, 3).join(" | ") : ""));
|
|
455
523
|
throw mwErr;
|
|
456
524
|
}
|
|
457
525
|
if (!next || res.writableEnded) return;
|
|
@@ -486,7 +554,7 @@ class Router {
|
|
|
486
554
|
if (this.notFoundHandler) {
|
|
487
555
|
this.notFoundHandler(req, res);
|
|
488
556
|
} else {
|
|
489
|
-
res.writeHead(
|
|
557
|
+
res.writeHead(HTTP_STATUS.NOT_FOUND, { "Content-Type": "text/html" });
|
|
490
558
|
res.end("<h1>404 Not Found</h1>");
|
|
491
559
|
}
|
|
492
560
|
}
|
|
@@ -515,7 +583,7 @@ class Router {
|
|
|
515
583
|
var requestHandler = (req, res) => {
|
|
516
584
|
// Response helpers
|
|
517
585
|
res.json = (data) => {
|
|
518
|
-
res.writeHead(res.statusCode ||
|
|
586
|
+
res.writeHead(res.statusCode || HTTP_STATUS.OK, { "Content-Type": "application/json" });
|
|
519
587
|
res.end(JSON.stringify(data));
|
|
520
588
|
};
|
|
521
589
|
res.redirect = (url) => {
|
|
@@ -525,6 +593,7 @@ class Router {
|
|
|
525
593
|
if (typeof url === "string" && url.startsWith("/") && !url.startsWith("//")) {
|
|
526
594
|
safe = url;
|
|
527
595
|
}
|
|
596
|
+
// 302 Found — RFC 7231 §6.4.3. Not in HTTP_STATUS table.
|
|
528
597
|
res.writeHead(302, { Location: safe });
|
|
529
598
|
res.end();
|
|
530
599
|
};
|
|
@@ -534,17 +603,17 @@ class Router {
|
|
|
534
603
|
};
|
|
535
604
|
|
|
536
605
|
self.handle(req, res).catch((err) => {
|
|
537
|
-
|
|
538
|
-
err.stack ? err.stack.split("\n").slice(0, 5).join(" | ") : "");
|
|
606
|
+
log.error("route error: " + req.method + " " + req.url + " " + err.message + " " +
|
|
607
|
+
(err.stack ? err.stack.split("\n").slice(0, 5).join(" | ") : ""));
|
|
539
608
|
if (self.errorHandler) {
|
|
540
609
|
try { self.errorHandler(err, req, res); } catch (_) {
|
|
541
610
|
if (!res.writableEnded) {
|
|
542
|
-
res.writeHead(
|
|
611
|
+
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, { "Content-Type": "text/plain" });
|
|
543
612
|
res.end("Internal Server Error");
|
|
544
613
|
}
|
|
545
614
|
}
|
|
546
615
|
} else if (!res.writableEnded) {
|
|
547
|
-
res.writeHead(
|
|
616
|
+
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, { "Content-Type": "text/plain" });
|
|
548
617
|
res.end("Internal Server Error");
|
|
549
618
|
}
|
|
550
619
|
});
|
|
@@ -594,8 +663,8 @@ class Router {
|
|
|
594
663
|
"Content-Length: " + Buffer.byteLength(body, "utf8") + "\r\n" +
|
|
595
664
|
"\r\n" +
|
|
596
665
|
body;
|
|
597
|
-
try { socket.write(resp); } catch (_e) {}
|
|
598
|
-
try { socket.destroy(); } catch (_e) {}
|
|
666
|
+
try { socket.write(resp); } catch (_e) { /* socket already closed */ }
|
|
667
|
+
try { socket.destroy(); } catch (_e) { /* socket already destroyed */ }
|
|
599
668
|
return;
|
|
600
669
|
}
|
|
601
670
|
var conn = websocket.handleUpgrade(req, socket, head, route.opts);
|
|
@@ -618,14 +687,14 @@ class Router {
|
|
|
618
687
|
var pathname = String(headers[":path"] || "/").split("?")[0];
|
|
619
688
|
var route = self._wsRoutes.get(pathname);
|
|
620
689
|
if (!route) {
|
|
621
|
-
try { stream.respond({ ":status": 404 }); stream.end(); } catch (_e) {}
|
|
690
|
+
try { stream.respond({ ":status": 404 }); stream.end(); } catch (_e) { /* stream already closed */ }
|
|
622
691
|
return;
|
|
623
692
|
}
|
|
624
693
|
if (route.transport === "h1-only") {
|
|
625
694
|
try {
|
|
626
695
|
stream.respond({ ":status": 405, "content-type": "text/plain; charset=utf-8" });
|
|
627
696
|
stream.end("WebSocket on this path requires HTTP/1.1 Upgrade");
|
|
628
|
-
} catch (_e) {}
|
|
697
|
+
} catch (_e) { /* stream already closed */ }
|
|
629
698
|
return;
|
|
630
699
|
}
|
|
631
700
|
var conn = websocket.handleExtendedConnect(stream, headers, route.opts);
|
|
@@ -664,7 +733,7 @@ function serveStatic(dir) {
|
|
|
664
733
|
var cacheControl = hasVersion
|
|
665
734
|
? "public, max-age=31536000, immutable"
|
|
666
735
|
: "public, max-age=3600";
|
|
667
|
-
res.writeHead(
|
|
736
|
+
res.writeHead(HTTP_STATUS.OK, {
|
|
668
737
|
"Content-Type": mime,
|
|
669
738
|
"Content-Length": stat.size,
|
|
670
739
|
"Cache-Control": cacheControl,
|