@blamejs/core 0.14.26 → 0.14.27

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.
@@ -28,7 +28,6 @@ var net = require("node:net");
28
28
  var nodeTls = require("node:tls");
29
29
  var nodeUrl = require("node:url");
30
30
  var C = require("./constants");
31
- var safeAsync = require("./safe-async");
32
31
  var validateOpts = require("./validate-opts");
33
32
  var ipUtils = require("./ip-utils");
34
33
  var { RedisError } = require("./framework-error");
@@ -215,11 +214,28 @@ function create(opts) {
215
214
  var connected = false;
216
215
  var connecting = false;
217
216
  var closing = false;
217
+ // Shared in-flight connect. Every _connect() call returns the SAME
218
+ // promise while a connect is in progress, so concurrent callers all
219
+ // observe the same resolve/reject instead of polling a flag. It is
220
+ // ALWAYS settled (resolve on ready, reject on socket-error / connect-
221
+ // timeout / AUTH-or-SELECT failure) and cleared the moment it settles
222
+ // so the next caller starts a fresh attempt — a connect that fails
223
+ // can never leave a never-settling promise behind for the next
224
+ // awaiter to wedge on.
225
+ var connectPromise = null;
218
226
  // Tracked + unref'd reconnect timer. Tracked so close() can cancel a
219
227
  // pending backoff (otherwise a reconnect scheduled before close fires
220
228
  // after it and opens a fresh socket); unref'd so a backoff window doesn't
221
229
  // by itself keep the event loop alive (the process-won't-exit class).
230
+ // Single-flight: a non-null reconnectTimer means a backoff is already
231
+ // pending — socket-error AND socket-close firing for the same failure
232
+ // must not stack two timers (which would burn the reconnect budget at
233
+ // 2x and open redundant sockets).
222
234
  var reconnectTimer = null;
235
+ // Set once the reconnect budget is exhausted. Makes the give-up path
236
+ // idempotent (drains pending+backlog exactly once) and stops a stray
237
+ // close/error after give-up from re-draining or racing a later success.
238
+ var gaveUp = false;
223
239
  var rxBuffer = Buffer.alloc(0);
224
240
  // FIFO of in-flight commands awaiting a response
225
241
  var pending = [];
@@ -239,18 +255,31 @@ function create(opts) {
239
255
 
240
256
  function _scheduleReconnect() {
241
257
  if (closing) return;
258
+ // Single-flight: a socket failure surfaces as both an `error` and a
259
+ // `close` event. Without this guard each one schedules its own timer,
260
+ // stacking two reconnects for one failure — the budget burns at 2x
261
+ // and two fresh sockets open. A pending backoff already covers the
262
+ // failure, so a second call is a no-op.
263
+ if (reconnectTimer !== null) return;
242
264
  if (maxReconnectAttempts >= 0 && reconnectAttempt >= maxReconnectAttempts) {
243
- // Drain pending callbacks with a clear error
265
+ // Reconnect budget exhausted. Drain pending + backlog exactly once;
266
+ // a later stray close/error must not re-drain or race a future
267
+ // success path.
268
+ if (gaveUp) return;
269
+ gaveUp = true;
244
270
  var err = _err("RECONNECT_GAVE_UP",
245
271
  "redis: gave up after " + reconnectAttempt + " reconnect attempts");
246
272
  _drainPending(err);
247
273
  return;
248
274
  }
249
275
  reconnectAttempt++;
276
+ // Exponential backoff capped at 30s. Base 100ms is the first-retry
277
+ // delay (not a duration unit), so it stays a literal; the cap routes
278
+ // through C.TIME.
250
279
  var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
251
280
  reconnectTimer = setTimeout(function () {
252
281
  reconnectTimer = null;
253
- _connect().catch(function () { /* will reschedule */ });
282
+ _connect().catch(function () { /* failure reschedules via the teardown path */ });
254
283
  }, delay);
255
284
  if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
256
285
  }
@@ -261,7 +290,10 @@ function create(opts) {
261
290
  batch.forEach(function (p) { p.reject(err); });
262
291
  var bl = backlog.slice();
263
292
  backlog.length = 0;
264
- bl.forEach(function (p) { p.reject(err); });
293
+ bl.forEach(function (p) {
294
+ if (p.timer) { clearTimeout(p.timer); p.timer = null; }
295
+ p.reject(err);
296
+ });
265
297
  }
266
298
 
267
299
  function _onData(chunk) {
@@ -312,39 +344,74 @@ function create(opts) {
312
344
  }
313
345
  }
314
346
 
315
- function _onSocketError(err) {
316
- var werr = _err("SOCKET", "redis socket error: " + ((err && err.message) || String(err)));
317
- _drainPending(werr);
347
+ // Single teardown path for a lost socket. A failure surfaces as an
348
+ // `error` event AND a `close` event (and `error` then destroys the
349
+ // socket, which fires `close` again) — three callbacks for ONE lost
350
+ // connection. Routing all of them here, guarded by a "are we still
351
+ // attached to this socket" check, means pending is drained once and
352
+ // exactly one reconnect is scheduled (the single-flight guard in
353
+ // _scheduleReconnect absorbs the rest). `err` is the diagnostic to
354
+ // reject in-flight commands with.
355
+ function _teardownSocket(err) {
356
+ // Already torn down for this socket (the sibling event already ran).
357
+ if (!connected && socket === null) {
358
+ // Still let a stray event re-arm a reconnect if one isn't pending
359
+ // and we haven't been closed — but never re-drain pending.
360
+ if (!closing) _scheduleReconnect();
361
+ return;
362
+ }
318
363
  connected = false;
319
- try { if (socket) socket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
364
+ var dead = socket;
320
365
  socket = null;
366
+ if (dead) {
367
+ try {
368
+ dead.removeListener("error", _onSocketError);
369
+ dead.removeListener("close", _onSocketClose);
370
+ dead.removeListener("data", _onData);
371
+ dead.destroy();
372
+ } catch (_e) { /* best-effort socket teardown */ }
373
+ }
374
+ _drainPending(err);
321
375
  if (!closing) _scheduleReconnect();
322
376
  }
323
377
 
378
+ function _onSocketError(err) {
379
+ _teardownSocket(_err("SOCKET",
380
+ "redis socket error: " + ((err && err.message) || String(err))));
381
+ }
382
+
324
383
  function _onSocketClose() {
325
- connected = false;
326
- if (!closing) {
327
- var err = _err("SOCKET_CLOSED", "redis socket closed unexpectedly");
328
- _drainPending(err);
329
- socket = null;
330
- _scheduleReconnect();
331
- }
384
+ _teardownSocket(_err("SOCKET_CLOSED", "redis socket closed unexpectedly"));
332
385
  }
333
386
 
334
- async function _connect() {
335
- // A reconnect timer scheduled before close() can still fire afterward;
336
- // refuse to re-open once closing so it doesn't leak a fresh socket.
337
- if (closing) return;
338
- if (connected) return;
339
- if (connecting) {
340
- // Wait until current connect attempt resolves
341
- while (connecting) await safeAsync.sleep(20);
342
- return;
343
- }
387
+ // _connect() — public entry. Returns a promise that ALWAYS settles.
388
+ // Concurrent callers (and the reconnect timer) share the single
389
+ // in-flight connectPromise rather than each starting a parallel dial,
390
+ // and they all observe the same resolve/reject. A previous version
391
+ // polled a `connecting` flag in a `while (connecting) await sleep(20)`
392
+ // loop; if a failure path failed to clear that flag the waiter spun
393
+ // forever. The shared promise removes that wedge — the promise is
394
+ // cleared the instant it settles, so a failed connect can never leave
395
+ // a never-settling promise behind.
396
+ function _connect() {
397
+ if (closing) return Promise.resolve();
398
+ if (connected) return Promise.resolve();
399
+ if (connectPromise) return connectPromise;
400
+ connectPromise = _doConnect();
401
+ // Clear the shared promise once it settles (either way) so the next
402
+ // _connect() starts a fresh attempt instead of re-awaiting a stale
403
+ // settled promise.
404
+ var clear = function () { connectPromise = null; };
405
+ connectPromise.then(clear, clear);
406
+ return connectPromise;
407
+ }
408
+
409
+ async function _doConnect() {
344
410
  connecting = true;
345
411
  rxBuffer = Buffer.alloc(0);
412
+ var newSocket = null;
346
413
  try {
347
- socket = await new Promise(function (resolve, reject) {
414
+ newSocket = await new Promise(function (resolve, reject) {
348
415
  var sock;
349
416
  var timer = setTimeout(function () {
350
417
  try { if (sock) sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
@@ -371,16 +438,19 @@ function create(opts) {
371
438
  }
372
439
  sock.once("error", onErr);
373
440
  });
441
+ socket = newSocket;
374
442
  socket.setNoDelay(true);
375
443
  socket.on("data", _onData);
376
444
  socket.on("error", _onSocketError);
377
445
  socket.on("close", _onSocketClose);
378
446
  connected = true;
379
- reconnectAttempt = 0;
380
447
 
381
448
  // Auth + select db on (re)connect — without resetting the
382
449
  // backlog of commands queued during disconnect. Send these
383
450
  // BEFORE the backlog so the server is ready when backlog flushes.
451
+ // A failure here (wrong password, server SELECT rejection, socket
452
+ // dropped mid-AUTH) must not leave connected=true on a half-open
453
+ // socket — the catch below tears the socket down and rethrows.
384
454
  if (password) {
385
455
  var authArgs = username ? ["AUTH", username, password] : ["AUTH", password];
386
456
  await _sendNoQueue(authArgs);
@@ -389,15 +459,47 @@ function create(opts) {
389
459
  await _sendNoQueue(["SELECT", String(db)]);
390
460
  }
391
461
 
392
- // Flush backlog
462
+ // Connect fully succeeded — only now reset the backoff counter +
463
+ // the give-up latch so a future disconnect gets a fresh budget.
464
+ reconnectAttempt = 0;
465
+ gaveUp = false;
466
+ connecting = false;
467
+
468
+ // Flush backlog. Clear each queued entry's not-connected timeout
469
+ // before it goes on the wire — the in-flight command timeout in
470
+ // _writeAndAwait now owns its lifetime.
393
471
  var bl = backlog.slice();
394
472
  backlog.length = 0;
395
- bl.forEach(function (entry) { _writeAndAwait(entry.args, entry.resolve, entry.reject); });
473
+ bl.forEach(function (entry) {
474
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
475
+ _writeAndAwait(entry.args, entry.resolve, entry.reject);
476
+ });
396
477
  } catch (err) {
397
478
  connecting = false;
479
+ connected = false;
480
+ // Tear down a half-open socket (came up, then AUTH/SELECT failed)
481
+ // so we never leave connected=false with a live socket whose data/
482
+ // error/close handlers would fire against stale state. If the
483
+ // socket-error handler already ran it set socket=null.
484
+ var dead = socket || newSocket;
485
+ socket = null;
486
+ if (dead) {
487
+ try {
488
+ dead.removeListener("error", _onSocketError);
489
+ dead.removeListener("close", _onSocketClose);
490
+ dead.removeListener("data", _onData);
491
+ dead.destroy();
492
+ } catch (_e) { /* best-effort socket teardown */ }
493
+ }
494
+ // A failed dial (reset before ready) or an AUTH/SELECT failure must keep
495
+ // the reconnect loop alive. The post-ready error/close handlers that
496
+ // normally drive reconnect are not attached yet during the dial, so
497
+ // without scheduling here a connection lost mid-dial rejects the connect
498
+ // promise and the client never reconnects. Single-flight + budget-guarded
499
+ // by _scheduleReconnect; the caller still observes this attempt's rejection.
500
+ if (!closing) _scheduleReconnect();
398
501
  throw err;
399
502
  }
400
- connecting = false;
401
503
  }
402
504
 
403
505
  // Internal helper that bypasses the connect-pending backlog (used
@@ -448,7 +550,28 @@ function create(opts) {
448
550
  return;
449
551
  }
450
552
  if (!connected) {
451
- backlog.push({ args: args, resolve: resolve, reject: reject });
553
+ // Reconnect budget exhausted and no reconnect is in flight — a
554
+ // backlogged command here would never be flushed (nothing will
555
+ // reconnect to drain it) and would wedge the caller forever.
556
+ // Reject immediately instead.
557
+ if (gaveUp && reconnectTimer === null && !connecting) {
558
+ reject(_err("RECONNECT_GAVE_UP",
559
+ "redis: client disconnected and reconnect budget exhausted"));
560
+ return;
561
+ }
562
+ // Queued until the next successful connect flushes the backlog.
563
+ // Bound it with a timeout so a connect that never completes (the
564
+ // backend is down for the whole window) settles the caller with
565
+ // a clear error instead of leaving the await pending forever.
566
+ var entry = { args: args, resolve: resolve, reject: reject, timer: null };
567
+ entry.timer = setTimeout(function () {
568
+ var idx = backlog.indexOf(entry);
569
+ if (idx !== -1) backlog.splice(idx, 1);
570
+ reject(_err("COMMAND_TIMEOUT",
571
+ "redis " + args[0] + " timed out while queued (client not connected)"));
572
+ }, commandTimeoutMs);
573
+ if (typeof entry.timer.unref === "function") entry.timer.unref();
574
+ backlog.push(entry);
452
575
  return;
453
576
  }
454
577
  _writeAndAwait(args, resolve, reject);
@@ -469,6 +592,9 @@ function create(opts) {
469
592
  async function close() {
470
593
  closing = true;
471
594
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
595
+ // Drop the shared connect promise so a re-create after close (or a
596
+ // late awaiter) doesn't re-await a stale in-flight attempt.
597
+ connectPromise = null;
472
598
  var err = _err("CLOSED", "redis client closed");
473
599
  _drainPending(err);
474
600
  if (socket) {
@@ -492,8 +618,11 @@ function create(opts) {
492
618
  _state: function () {
493
619
  return {
494
620
  connected: connected, closing: closing,
621
+ connecting: connecting,
495
622
  pending: pending.length, backlog: backlog.length,
496
623
  reconnect: reconnectAttempt,
624
+ reconnectPending: reconnectTimer !== null,
625
+ gaveUp: gaveUp,
497
626
  host: host, port: port, db: db, tls: useTls,
498
627
  connectTimeoutMs: connectTimeoutMs,
499
628
  commandTimeoutMs: commandTimeoutMs,
package/lib/router.js CHANGED
@@ -42,6 +42,7 @@ var lazyRequire = require("./lazy-require");
42
42
  var safeAsync = require("./safe-async");
43
43
  var safeEnv = require("./parsers/safe-env");
44
44
  var safeUrl = require("./safe-url");
45
+ var validateOpts = require("./validate-opts");
45
46
  var websocket = require("./websocket");
46
47
  var { boot } = require("./log");
47
48
  var { RouterError } = require("./framework-error");
@@ -301,6 +302,13 @@ class Router {
301
302
  constructor(opts) {
302
303
  opts = opts || {};
303
304
  this.routes = [];
305
+ // Registration-ordered middleware table. Each entry is
306
+ // `{ prefix, prefixSegments, fn }`: `prefix === null` is a global
307
+ // middleware (runs on every request); a non-null `prefix` is a
308
+ // path-scoped middleware that runs only when the request path
309
+ // matches the prefix on segment boundaries. Path-scoped and global
310
+ // entries interleave in registration order so a gate registered
311
+ // before a route still runs before it.
304
312
  this.middleware = [];
305
313
  // WebSocket routes are kept separate from HTTP routes — they're
306
314
  // matched on the upgrade / Extended CONNECT nodePath, not on a method
@@ -453,8 +461,23 @@ class Router {
453
461
  return conns.length;
454
462
  }
455
463
 
456
- use(fn) {
457
- this.middleware.push(fn);
464
+ // use(mw) global middleware (runs on every request)
465
+ // use(mw1, mw2, ...) — several global middlewares, in order
466
+ // use(prefix, mw1, mw2, ...) — path-scoped: mw runs only when the
467
+ // request path is at or beneath `prefix`
468
+ // on segment boundaries ("/admin" covers
469
+ // "/admin" + "/admin/x", not "/administrator")
470
+ // use([prefixA, prefixB], mw) — scoped to any of several prefixes
471
+ //
472
+ // Bad input throws at config time (a non-string / non-array prefix, a
473
+ // prefix that doesn't begin with "/", a non-function middleware) so an
474
+ // operator wiring typo surfaces at boot instead of silently dropping a
475
+ // security gate or 500-ing every request.
476
+ use() {
477
+ var entries = _normalizeUseArgs(Array.prototype.slice.call(arguments));
478
+ for (var i = 0; i < entries.length; i++) {
479
+ this.middleware.push(entries[i]);
480
+ }
458
481
  }
459
482
 
460
483
  // Internal: split a route registration's args into { spec, handlers }.
@@ -687,8 +710,24 @@ class Router {
687
710
  }
688
711
  req.query = Object.fromEntries(queryEntries);
689
712
 
690
- // Run middleware
691
- for (var mw of this.middleware) {
713
+ // Run middleware in registration order. Global entries
714
+ // (prefixSegmentsList === null) run on every request; path-scoped
715
+ // entries run only when req.pathname is at or beneath one of the
716
+ // mount's prefixes (segment-boundary match). A skipped scoped
717
+ // middleware does NOT short-circuit the chain — the next entry
718
+ // still runs.
719
+ for (var entry of this.middleware) {
720
+ if (entry.prefixSegmentsList !== null) {
721
+ var matched = false;
722
+ for (var pli = 0; pli < entry.prefixSegmentsList.length; pli++) {
723
+ if (_pathMatchesPrefix(entry.prefixSegmentsList[pli], req.pathname)) {
724
+ matched = true;
725
+ break;
726
+ }
727
+ }
728
+ if (!matched) continue;
729
+ }
730
+ var mw = entry.fn;
692
731
  var next = false;
693
732
  try {
694
733
  await mw(req, res, () => (next = true));
@@ -1206,6 +1245,152 @@ class Router {
1206
1245
  }
1207
1246
  }
1208
1247
 
1248
+ // ---- Path-scoped `use()` helpers ----
1249
+ //
1250
+ // These back `Router.use(prefix, mw)`. They live after the class
1251
+ // (hoisted function declarations are visible to the methods regardless
1252
+ // of source order) so the class methods sit contiguous with the
1253
+ // route-matching helpers above.
1254
+
1255
+ // Compile a `use(prefix, mw)` path prefix into the segment list the
1256
+ // matcher walks. A prefix is the literal-segment portion of a path
1257
+ // ("/admin", "/.well-known/jmap") — parameter segments (":id") are not
1258
+ // meaningful for a mounting prefix, so they're treated as literals.
1259
+ //
1260
+ // Normalization mirrors route-pattern handling: split on "/" and drop a
1261
+ // single trailing-slash artifact so "/admin" and "/admin/" mount the
1262
+ // same. The leading empty segment from the leading "/" is preserved so
1263
+ // the prefix anchors at the path root (a prefix that does not begin with
1264
+ // "/" is refused at the use() entry point).
1265
+ function _compilePrefix(prefix) {
1266
+ var segments = prefix.split("/");
1267
+ // A trailing "/" produces a final empty segment ("/admin/" → ["", "admin", ""]).
1268
+ // Drop it so the trailing slash doesn't force an extra path segment.
1269
+ if (segments.length > 1 && segments[segments.length - 1] === "") {
1270
+ segments.pop();
1271
+ }
1272
+ return segments;
1273
+ }
1274
+
1275
+ // True when `pathname` is at or beneath the mounting prefix, matching on
1276
+ // segment boundaries. "/admin" matches "/admin" and "/admin/x" but NOT
1277
+ // "/administrator" (Express-style segment semantics) — a substring
1278
+ // prefix that lands mid-segment is a no-match so a security gate scoped
1279
+ // to "/admin" never leaks onto a sibling path that merely shares a
1280
+ // textual prefix.
1281
+ function _pathMatchesPrefix(prefixSegments, pathname) {
1282
+ var pathSegments = pathname.split("/");
1283
+ if (pathSegments.length < prefixSegments.length) return false;
1284
+ // Compare segment-for-segment. This is routing metadata (public URL
1285
+ // path), not secret material, so an ordinary equality walk is correct
1286
+ // — no constant-time comparison is warranted.
1287
+ return prefixSegments.every(function (seg, i) {
1288
+ return pathSegments[i] === seg;
1289
+ });
1290
+ }
1291
+
1292
+ // Classify the first `use()` argument:
1293
+ // function → global mount (prefixes stays null)
1294
+ // string / string[] → path-scoped mount (one or more prefixes)
1295
+ // anything else → operator wiring typo, refused at config time
1296
+ // Returns the prefix array (or null for a global mount). Does not touch
1297
+ // the middleware functions — the caller validates those.
1298
+ function _usePrefixesFromFirstArg(first) {
1299
+ if (typeof first === "function") return null;
1300
+ if (typeof first !== "string" && !Array.isArray(first)) {
1301
+ throw new RouterError("router/use-bad-first-arg",
1302
+ "router.use: first argument must be a middleware function, a path " +
1303
+ "prefix string, or an array of prefix strings (got " +
1304
+ (first === null ? "null" : typeof first) + ")");
1305
+ }
1306
+ var prefixes = Array.isArray(first) ? first : [first];
1307
+ if (prefixes.length === 0) {
1308
+ throw new RouterError("router/use-empty-prefix-array",
1309
+ "router.use: path-prefix array must contain at least one prefix string");
1310
+ }
1311
+ // Array-of-non-empty-strings shape (the index-pointing throw on a
1312
+ // non-string / empty entry) is the shared validate-opts contract.
1313
+ validateOpts.optionalNonEmptyStringArray(
1314
+ prefixes, "router.use: path prefix", RouterError, "router/use-prefix-not-string");
1315
+ // Prefix-specific grammar: anchor at "/" + bounded length.
1316
+ for (var i = 0; i < prefixes.length; i++) {
1317
+ var grammarErr = _prefixGrammarError(prefixes[i]);
1318
+ if (grammarErr) throw grammarErr;
1319
+ }
1320
+ return prefixes;
1321
+ }
1322
+
1323
+ // Return a RouterError describing why `prefix` is not a valid mounting
1324
+ // prefix, or null when it is valid. Split out so the per-prefix grammar
1325
+ // check is one branch, not an inline throw-block in the loop.
1326
+ function _prefixGrammarError(prefix) {
1327
+ if (prefix.charAt(0) !== "/") {
1328
+ return new RouterError("router/use-prefix-not-absolute",
1329
+ "router.use: path prefix '" + prefix + "' must begin with '/'");
1330
+ }
1331
+ if (prefix.length > MAX_ROUTE_PATTERN_LEN) {
1332
+ return new RouterError("router/use-prefix-too-long",
1333
+ "router.use: path prefix exceeds " + MAX_ROUTE_PATTERN_LEN +
1334
+ " chars (got " + prefix.length + ")");
1335
+ }
1336
+ return null;
1337
+ }
1338
+
1339
+ // Index of the first non-function entry in `fns`, or -1 when all are
1340
+ // functions. Kept separate so the middleware-shape check is a scan, not
1341
+ // an inline throw inside the normalize flow.
1342
+ function _firstNonFunctionIndex(fns) {
1343
+ for (var i = 0; i < fns.length; i++) {
1344
+ if (typeof fns[i] !== "function") return i;
1345
+ }
1346
+ return -1;
1347
+ }
1348
+
1349
+ // Validate + normalize the `use()` arguments into middleware-table
1350
+ // entries. Config-time entry-point tier: a non-string / non-array-of-
1351
+ // strings prefix or a non-function middleware is an operator wiring
1352
+ // typo and throws so it surfaces at boot, not as a silent dropped gate
1353
+ // or a request-time 500. Returns one entry per middleware function:
1354
+ // { prefix: null, prefixSegmentsList: null, fn } — global
1355
+ // { prefix: "/admin", prefixSegmentsList: [[...], ...], fn } — scoped
1356
+ // A scoped entry matches when ANY of its prefixes match the request
1357
+ // path on segment boundaries; the fn runs at most once per request even
1358
+ // when two of its prefixes both match (nested prefixes), so a gate never
1359
+ // double-executes.
1360
+ function _normalizeUseArgs(args) {
1361
+ if (args.length === 0) {
1362
+ throw new RouterError("router/use-no-args",
1363
+ "router.use: requires at least one middleware function");
1364
+ }
1365
+ var prefixes = _usePrefixesFromFirstArg(args[0]);
1366
+ // Global mount uses every arg as a middleware; a scoped mount drops the
1367
+ // leading prefix arg and uses the rest.
1368
+ var fns = (prefixes === null) ? args : args.slice(1);
1369
+ if (fns.length === 0) {
1370
+ throw new RouterError("router/use-no-middleware",
1371
+ "router.use: path-scoped mount requires at least one middleware " +
1372
+ "function after the prefix");
1373
+ }
1374
+ var nonFn = _firstNonFunctionIndex(fns);
1375
+ if (nonFn !== -1) {
1376
+ throw new RouterError("router/use-middleware-not-function",
1377
+ "router.use: middleware at position " + nonFn +
1378
+ " must be a function (got " +
1379
+ (fns[nonFn] === null ? "null" : typeof fns[nonFn]) + ")");
1380
+ }
1381
+ // Pre-compile each prefix to its segment list once at registration so
1382
+ // dispatch only walks segments, never re-splits the prefix per request.
1383
+ var segmentsList = (prefixes === null) ? null : prefixes.map(_compilePrefix);
1384
+ var label = (prefixes === null) ? null
1385
+ : (prefixes.length === 1 ? prefixes[0] : prefixes.slice());
1386
+ // One entry per fn, preserving the order each middleware was passed —
1387
+ // a path-scoped mount with two middlewares interleaves them in
1388
+ // registration order just like two separate use() calls would.
1389
+ return fns.map(function (fn) {
1390
+ return { prefix: label, prefixSegmentsList: segmentsList, fn: fn };
1391
+ });
1392
+ }
1393
+
1209
1394
  /**
1210
1395
  * @primitive b.router.serveStatic
1211
1396
  * @signature b.router.serveStatic(dir)
@@ -1266,7 +1451,7 @@ function serveStatic(dir) {
1266
1451
  *
1267
1452
  * Builds a `Router` instance with the framework's security-on-by-
1268
1453
  * default posture. Returned object exposes `get / post / put / patch
1269
- * / delete` for route registration, `use(fn)` for global middleware,
1454
+ * / delete` for route registration, `use(...)` for middleware,
1270
1455
  * `ws(path, handler, opts?)` for WebSocket routes, `onNotFound(fn)`
1271
1456
  * and `onError(fn)` for fallthrough hooks, `inspectRoutes()` and
1272
1457
  * `openapi()` for introspection, `closeWebSockets({ timeoutMs })`
@@ -1274,6 +1459,21 @@ function serveStatic(dir) {
1274
1459
  * which boots an HTTP/2-capable TLS server (ALPN h2 + http/1.1) when
1275
1460
  * `tlsOptions` is provided, an HTTP/1.1 server otherwise.
1276
1461
  *
1462
+ * `use` has two forms. `use(mw)` (and `use(mw1, mw2, ...)`) mounts
1463
+ * global middleware that runs on every request. `use(prefix, mw1,
1464
+ * mw2, ...)` mounts path-scoped middleware that runs only when the
1465
+ * request path is at or beneath `prefix`, matched on segment
1466
+ * boundaries — `"/admin"` covers `"/admin"` and `"/admin/x"` but not
1467
+ * `"/administrator"`. The prefix may be an array of strings to scope a
1468
+ * gate to several path roots at once. Global and scoped middleware
1469
+ * interleave in registration order, so a gate registered before a
1470
+ * route still runs before it. A non-string / non-array prefix, a
1471
+ * prefix not beginning with `"/"`, or a non-function middleware throws
1472
+ * at registration time rather than dropping the gate or 500-ing every
1473
+ * request — scope a security middleware (`csrf`, `bearerAuth`,
1474
+ * `requireAal`, `requireMtls`) to a path with confidence it runs
1475
+ * exactly where mounted.
1476
+ *
1277
1477
  * @opts
1278
1478
  * tls0Rtt: "refuse" | "replay-cache", // RFC 8446 §8 anti-replay; default "refuse"
1279
1479
  * allowedRedirectOrigins: string[], // exact-match HTTPS origins for cross-origin res.redirect()
@@ -1286,6 +1486,13 @@ function serveStatic(dir) {
1286
1486
  * router.get("/users/:id", function (req, res) {
1287
1487
  * res.json({ id: req.params.id });
1288
1488
  * });
1489
+ *
1490
+ * // Global middleware — runs on every request.
1491
+ * router.use(b.middleware.securityHeaders());
1492
+ *
1493
+ * // Path-scoped middleware — the step-up gate runs only under /admin.
1494
+ * router.use("/admin", b.middleware.requireAal({ minimum: "AAL2" }));
1495
+ *
1289
1496
  * router.listen(3000);
1290
1497
  */
1291
1498
  function create(opts) {
package/lib/ssrf-guard.js CHANGED
@@ -148,12 +148,27 @@ var IPV6_6TO4_PREFIX = _ipv6ToBytes("2002::");
148
148
  // or attempted exfil to a sinkhole.
149
149
  var IPV6_DISCARD_PREFIX = _ipv6ToBytes("100::");
150
150
 
151
- // ---- Cloud metadata addresses (string-equality, exact match) ----
151
+ // ---- Cloud metadata addresses (matched on CANONICAL bytes, not string) ----
152
+ // The documentation strings below are the human-readable canonical forms.
153
+ // Matching is byte-canonical (see _isCloudMetadataAddr): an IPv6 address has
154
+ // many textual representations (compressed `::`, fully-expanded
155
+ // `fd00:ec2:0:0:0:0:0:254`, mixed-case) that all decode to the same 16 bytes.
156
+ // A string-equality membership test matched only ONE spelling, so a hostile
157
+ // (or merely DoH-decoded — network-dns.js emits the expanded form) answer of
158
+ // `fd00:ec2:0:0:0:0:0:254` slipped past as "private" and rode the documented
159
+ // `allowInternal:true` waiver straight into the IMDS credential endpoint.
152
160
  var CLOUD_METADATA_IPS = [
153
161
  "169.254.169.254", // AWS, GCP, Azure, OpenStack, DO
154
162
  "169.254.170.2", // AWS ECS task role
155
163
  "fd00:ec2::254", // AWS IMDS over IPv6
156
164
  ];
165
+ // Canonical byte forms of the metadata IPs — v4 as a 4-byte Buffer, v6 as a
166
+ // 16-byte Buffer. Built once at load via the same parsers classify() uses,
167
+ // so every textual representation that decodes to these bytes is caught.
168
+ var CLOUD_METADATA_BYTES = CLOUD_METADATA_IPS.map(function (ip) {
169
+ var fam = net.isIP(ip);
170
+ return fam === 4 ? _ipv4ToBytes(ip) : _ipv6ToBytes(ip);
171
+ });
157
172
 
158
173
  // ---- Helpers ----
159
174
 
@@ -180,6 +195,14 @@ function _ipv4ToInt(ip) {
180
195
  nums[3];
181
196
  }
182
197
 
198
+ function _ipv4ToBytes(ip) {
199
+ // Canonical 4-byte form of an IPv4 address. Returns null on malformed
200
+ // input so a metadata-membership test never matches garbage.
201
+ var n = _ipv4ToInt(ip);
202
+ if (!Number.isFinite(n)) return null;
203
+ return Buffer.from([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
204
+ }
205
+
183
206
  function _ipv6ToBytes(ip) {
184
207
  // Node's net.isIPv6 returns 6 for valid IPv6; we then expand
185
208
  // shorthand via manual parsing. node:net doesn't export an
@@ -309,7 +332,10 @@ function classify(ip) {
309
332
  var family = net.isIP(ip);
310
333
  if (family === 0) return null;
311
334
 
312
- if (CLOUD_METADATA_IPS.indexOf(ip) !== -1) return "cloud-metadata";
335
+ // Cloud-metadata IPs are matched on their canonical byte form so every
336
+ // textual spelling (compressed `::`, fully-expanded zero-runs, mixed
337
+ // case) is caught — a string-equality test matched one spelling only.
338
+ if (_isCloudMetadataAddr(ip, family)) return "cloud-metadata";
313
339
 
314
340
  if (family === 4) {
315
341
  var ipInt = _ipv4ToInt(ip);
@@ -349,6 +375,24 @@ function classify(ip) {
349
375
  return null;
350
376
  }
351
377
 
378
+ // Canonical-bytes membership test for the cloud-metadata IP set. An IP
379
+ // matches iff its parsed bytes equal one of CLOUD_METADATA_BYTES, regardless
380
+ // of textual representation. This is the unconditional metadata gate — it
381
+ // must NOT be string-based, because IPv6 has many spellings of the same
382
+ // address (the DoH resolver in network-dns.js, for instance, emits the
383
+ // fully-expanded `fd00:ec2:0:0:0:0:0:254` rather than the compressed form).
384
+ function _isCloudMetadataAddr(ip, family) {
385
+ var fam = typeof family === "number" ? family : net.isIP(ip);
386
+ if (fam === 0) return false;
387
+ var bytes = fam === 4 ? _ipv4ToBytes(ip) : _ipv6ToBytes(ip);
388
+ if (!bytes) return false;
389
+ for (var i = 0; i < CLOUD_METADATA_BYTES.length; i++) {
390
+ var ref = CLOUD_METADATA_BYTES[i];
391
+ if (ref && ref.length === bytes.length && _bufEqual(bytes, ref)) return true;
392
+ }
393
+ return false;
394
+ }
395
+
352
396
  function _bufEqual(a, b) {
353
397
  // Compares Buffer-like byte arrays for equality. The buffers here
354
398
  // are IP addresses, not secrets, so the comparison doesn't need
@@ -766,8 +810,11 @@ function checkUrlTextual(url, opts) {
766
810
  // If the textual hostname IS an IP literal AND matches a cloud-
767
811
  // metadata IP, refuse — even with `allowInternal: true` and a proxy.
768
812
  // Metadata IPs leak instance credentials (AWS IMDS, GCP, Azure) and
769
- // are not a configuration knob.
770
- if (net.isIP(host) && CLOUD_METADATA_IPS.indexOf(host) !== -1) {
813
+ // are not a configuration knob. Matched on canonical bytes so a
814
+ // non-canonical IPv6 spelling (compressed / expanded / mixed-case)
815
+ // can't slip the textual gate the way it slipped classify().
816
+ var hostFamily = net.isIP(host);
817
+ if (hostFamily !== 0 && _isCloudMetadataAddr(host, hostFamily)) {
771
818
  throw new ErrorClass(
772
819
  "URL '" + parsed.toString() + "' resolves to cloud-metadata IP " + host +
773
820
  " — refused unconditionally (not overridable via allowInternal + proxy)",