From 3d1e1d59bbcae08f19ae9384d39761c7de523443 Mon Sep 17 00:00:00 2001 From: Zhihua Lai Date: Thu, 11 Jun 2026 11:48:00 +0100 Subject: [PATCH 1/3] Refactor/modules and add tests for counters --- README.md | 2 +- js_tests/counters.test.js | 118 ++++++++++++++ src/config.js | 26 +++ src/counters.js | 144 +++++++++++++++++ src/health-check.js | 160 ++++++++++++++++++ src/index.js | 331 ++++---------------------------------- 6 files changed, 481 insertions(+), 300 deletions(-) create mode 100644 js_tests/counters.test.js create mode 100644 src/config.js create mode 100644 src/counters.js create mode 100644 src/health-check.js diff --git a/README.md b/README.md index eaab7c3..5a90c85 100755 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Steem Load Balancer -[![Build and Tests](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/ci.yaml) [![Test Coverage](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/coverage.yaml/badge.svg)](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/coverage.yaml) +[![Build and Tests](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/ci.yaml) [![Test Coverage](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/coverage.yaml/badge.svg)](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/coverage.yaml) [![Docker Pulls](https://img.shields.io/docker/pulls/justyy/steem-load-balancer)](https://hub.docker.com/r/justyy/steem-load-balancer) [![Docker Image Size](https://img.shields.io/docker/image-size/justyy/steem-load-balancer/latest)](https://hub.docker.com/r/justyy/steem-load-balancer) [![Docker Stars](https://img.shields.io/docker/stars/justyy/steem-load-balancer)](https://hub.docker.com/r/justyy/steem-load-balancer) Here is the [AI-generated documentation](https://deepwiki.com/DoctorLai/steem-load-balancer/) by Deep-Wiki. diff --git a/js_tests/counters.test.js b/js_tests/counters.test.js new file mode 100644 index 0000000..6ab5555 --- /dev/null +++ b/js_tests/counters.test.js @@ -0,0 +1,118 @@ +import { Counters, REQUEST_WINDOW_MS } from "../src/counters.js"; + +describe("Counters", () => { + let counters; + + beforeEach(() => { + counters = new Counters(); + }); + + test("starts with empty/zeroed state", () => { + expect(counters.total).toBe(0); + expect(counters.maxJussi).toBe(-1); + expect(counters.access.size).toBe(0); + expect(counters.error.size).toBe(0); + expect(counters.notChosen.size).toBe(0); + expect(counters.jussiBehind.size).toBe(0); + expect(counters.timedOut.size).toBe(0); + expect(counters.requestTimestamps).toEqual([]); + }); + + test("incrementAccess counts per server", async () => { + await counters.incrementAccess("https://a.com"); + await counters.incrementAccess("https://a.com"); + await counters.incrementAccess("https://b.com"); + expect(counters.access.get("https://a.com")).toBe(2); + expect(counters.access.get("https://b.com")).toBe(1); + }); + + test("per-server counters are independent", async () => { + await counters.incrementError("s"); + await counters.incrementNotChosen("s"); + await counters.incrementJussiBehind("s"); + await counters.incrementTimedOut("s"); + expect(counters.error.get("s")).toBe(1); + expect(counters.notChosen.get("s")).toBe(1); + expect(counters.jussiBehind.get("s")).toBe(1); + expect(counters.timedOut.get("s")).toBe(1); + }); + + test("incrementTotal increments the global counter", async () => { + await counters.incrementTotal(); + await counters.incrementTotal(); + expect(counters.total).toBe(2); + }); + + test("concurrent increments are serialized by the mutex", async () => { + await Promise.all( + Array.from({ length: 100 }, () => counters.incrementTotal()), + ); + expect(counters.total).toBe(100); + + await Promise.all( + Array.from({ length: 50 }, () => counters.incrementAccess("x")), + ); + expect(counters.access.get("x")).toBe(50); + }); + + test("updateMaxJussi keeps the highest value seen", async () => { + await counters.updateMaxJussi(100); + expect(counters.maxJussi).toBe(100); + await counters.updateMaxJussi(50); + expect(counters.maxJussi).toBe(100); + await counters.updateMaxJussi(150); + expect(counters.maxJussi).toBe(150); + }); + + test("recordRequest drops timestamps older than the tracking window", () => { + const now = 1_000_000_000; + counters.recordRequest(now - REQUEST_WINDOW_MS - 1); // expired + counters.recordRequest(now - 1000); // recent + counters.recordRequest(now); // current -> prunes using cutoff `now - window` + expect(counters.requestTimestamps).toEqual([now - 1000, now]); + }); + + test("calculateRPS computes per-second rates across the windows", () => { + const now = Date.now(); + for (let i = 0; i < 60; i++) { + counters.requestTimestamps.push(now); + } + const rps = counters.calculateRPS(); + expect(Object.keys(rps)).toEqual(["1min", "5min", "15min"]); + expect(rps["1min"]).toBe(1); // 60 / (1 * 60) + expect(rps["5min"]).toBe(0.2); // 60 / (5 * 60) + expect(rps["15min"]).toBe(0.07); // 60 / (15 * 60), rounded + }); + + test("getAccessPercentages reports percentage and count per server", async () => { + await counters.incrementAccess("a"); + await counters.incrementAccess("a"); + await counters.incrementAccess("b"); + await counters.incrementTotal(); + await counters.incrementTotal(); + await counters.incrementTotal(); + await counters.incrementTotal(); + expect(counters.getAccessPercentages()).toEqual({ + a: { percent: 50, count: 2 }, + b: { percent: 25, count: 1 }, + }); + }); + + test("getErrorPercentages reports error/success rates per server", async () => { + await counters.incrementAccess("a"); + await counters.incrementAccess("a"); + await counters.incrementError("a"); + expect(counters.getErrorPercentages()).toEqual({ + a: { errRate: 50, total: 2, errorCount: 1, succRate: 50 }, + }); + }); + + test("getNotChosen/getJussiBehind/getTimedOut return plain objects", async () => { + await counters.incrementNotChosen("a"); + await counters.incrementJussiBehind("b"); + await counters.incrementTimedOut("c"); + expect(counters.getNotChosen()).toEqual({ a: 1 }); + expect(counters.getJussiBehind()).toEqual({ b: 1 }); + expect(counters.getTimedOut()).toEqual({ c: 1 }); + }); +}); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..08fca0d --- /dev/null +++ b/src/config.js @@ -0,0 +1,26 @@ +import fs from "fs"; +import yaml from "js-yaml"; + +// Load the YAML configuration file located at `configPath`, replacing any +// `${ENV_VAR}` placeholders with the matching environment variable value. +// Exits the process if the file does not exist, mirroring the original +// startup behaviour. +function loadConfig(configPath) { + if (!fs.existsSync(configPath)) { + console.error(`Configuration file not found at ${configPath}`); + process.exit(1); + } + + // Load the YAML file content with environment variable replacement + const rawConfig = yaml.load(fs.readFileSync(configPath, "utf8")); + + // Replace environment variables in the loaded config + return JSON.parse( + JSON.stringify(rawConfig).replace( + /\$\{(.+?)\}/g, + (_, name) => process.env[name], + ), + ); +} + +export { loadConfig }; diff --git a/src/counters.js b/src/counters.js new file mode 100644 index 0000000..2107218 --- /dev/null +++ b/src/counters.js @@ -0,0 +1,144 @@ +import { Mutex } from "async-mutex"; +import { calculatePercentage, calculateErrorPercentage } from "./functions.js"; + +// How long request timestamps are retained for RPS calculations (15 minutes). +const REQUEST_WINDOW_MS = 15 * 60 * 1000; + +// Encapsulates all request/health statistics together with the mutexes that +// guard concurrent updates. Keeping this state in one place removes the heavy +// duplication of the "lock -> read -> increment -> set" pattern that used to be +// scattered throughout the request handling code. +class Counters { + constructor() { + // Per-server counters. + this.access = new Map(); + this.error = new Map(); + this.notChosen = new Map(); + this.jussiBehind = new Map(); + this.timedOut = new Map(); + + // Global counters. + this.total = 0; + this.maxJussi = -1; + + // Timestamps of recent requests, used to compute requests-per-second. + this.requestTimestamps = []; + + // Mutexes guarding each piece of mutable state. + this._mutexes = { + access: new Mutex(), + error: new Mutex(), + notChosen: new Mutex(), + jussiBehind: new Mutex(), + timedOut: new Mutex(), + total: new Mutex(), + maxJussi: new Mutex(), + }; + } + + // Atomically increment the counter for `key` inside `map`. + _incrementMapCounter(mutex, map, key) { + return mutex.runExclusive(() => { + map.set(key, (map.get(key) ?? 0) + 1); + }); + } + + incrementAccess(server) { + return this._incrementMapCounter(this._mutexes.access, this.access, server); + } + + incrementError(server) { + return this._incrementMapCounter(this._mutexes.error, this.error, server); + } + + incrementNotChosen(server) { + return this._incrementMapCounter( + this._mutexes.notChosen, + this.notChosen, + server, + ); + } + + incrementJussiBehind(server) { + return this._incrementMapCounter( + this._mutexes.jussiBehind, + this.jussiBehind, + server, + ); + } + + incrementTimedOut(server) { + return this._incrementMapCounter( + this._mutexes.timedOut, + this.timedOut, + server, + ); + } + + incrementTotal() { + return this._mutexes.total.runExclusive(() => { + this.total += 1; + }); + } + + // Track the highest jussi number seen so far across all healthy nodes. + updateMaxJussi(jussiNumber) { + return this._mutexes.maxJussi.runExclusive(() => { + this.maxJussi = Math.max(this.maxJussi, jussiNumber); + }); + } + + // Record a request timestamp and drop entries older than the tracking window. + recordRequest(now = Date.now()) { + this.requestTimestamps.push(now); + const cutoffTime = now - REQUEST_WINDOW_MS; + this.requestTimestamps = this.requestTimestamps.filter( + (timestamp) => timestamp > cutoffTime, + ); + } + + // Calculate requests-per-second over 1, 5 and 15 minute windows. + calculateRPS() { + const now = Date.now(); + + const intervals = { + "1min": now - 1 * 60 * 1000, + "5min": now - 5 * 60 * 1000, + "15min": now - 15 * 60 * 1000, + }; + + const rps = {}; + for (const [key, intervalStart] of Object.entries(intervals)) { + const requestsInInterval = this.requestTimestamps.filter( + (timestamp) => timestamp > intervalStart, + ).length; + rps[key] = parseFloat( + (requestsInInterval / (parseInt(key) * 60)).toFixed(2), + ); // requests per second + } + + return rps; + } + + getAccessPercentages() { + return calculatePercentage(this.access, this.total); + } + + getErrorPercentages() { + return calculateErrorPercentage(this.error, this.access); + } + + getNotChosen() { + return Object.fromEntries(this.notChosen); + } + + getJussiBehind() { + return Object.fromEntries(this.jussiBehind); + } + + getTimedOut() { + return Object.fromEntries(this.timedOut); + } +} + +export { Counters, REQUEST_WINDOW_MS }; diff --git a/src/health-check.js b/src/health-check.js new file mode 100644 index 0000000..bd7cd81 --- /dev/null +++ b/src/health-check.js @@ -0,0 +1,160 @@ +import { performance } from "perf_hooks"; + +import { fetchWithTimeout } from "./network.js"; +import { + log, + compareVersion, + isObjectEmptyOrNullOrUndefined, +} from "./functions.js"; + +// Build the `getServerData` health-check function bound to the supplied +// dependencies. The returned function probes a single node, validating its +// version and jussi number, and updates the shared counters accordingly. +function createGetServerData({ + agent, + timeout, + userAgent, + minBlockchainVersion, + maxJussiNumberDiff, + counters, +}) { + // Fetch version from the server + return async function getServerData(server) { + const startTime = performance.now(); // start timer + try { + const versionPromise = fetchWithTimeout( + server, + { + method: "POST", + cache: "no-cache", + mode: "cors", + redirect: "follow", + headers: { + "Content-Type": "application/json", + "User-Agent": userAgent, + }, + body: JSON.stringify({ + id: 0, + jsonrpc: "2.0", + method: "call", + params: ["login_api", "get_version", []], + }), + agent, + }, + timeout, + ); + + const jussiPromise = fetchWithTimeout( + server, + { + method: "GET", + cache: "no-cache", + mode: "cors", + redirect: "follow", + headers: { + "Content-Type": "application/json", + "User-Agent": userAgent, + }, + agent, + }, + timeout, + ); + + // Wait for both fetches to complete + const [ + { response: versionResponse, latency: versionLatency }, + { response: jussiResponse, latency: jussiLatency }, + ] = await Promise.all([versionPromise, jussiPromise]); + + const latencyMs = performance.now() - startTime; // end timer + log( + `Server ${server} Latency: ${latencyMs.toFixed(2)} ms (version: ${versionLatency} ms, jussi: ${jussiLatency} ms)`, + ); + + if (!versionResponse.ok) { + let err_msg = `Server ${server} (version) responded with status: ${versionResponse.status}`; + log(err_msg); + await counters.incrementNotChosen(server); + throw new Error(err_msg); + } + + if (!jussiResponse.ok) { + let err_msg = `Server ${server} (jussi_number) responded with status: ${jussiResponse.status}`; + log(err_msg); + await counters.incrementNotChosen(server); + throw new Error(err_msg); + } + + const jsonResponse = await versionResponse.json(); + + if ( + isObjectEmptyOrNullOrUndefined(jsonResponse) || + isObjectEmptyOrNullOrUndefined(jsonResponse["result"]) + ) { + let err_msg = `Server ${server} Invalid version response: ${JSON.stringify(jsonResponse)}`; + log(err_msg); + await counters.incrementNotChosen(server); + throw new Error(err_msg); + } + + const blockchain_version = jsonResponse["result"]["blockchain_version"]; + if (compareVersion(blockchain_version, minBlockchainVersion) == -1) { + let err_msg = `Server ${server} version = ${blockchain_version}: but min version is ${minBlockchainVersion}`; + log(err_msg); + await counters.incrementNotChosen(server); + throw new Error(err_msg); + } + + const jussi = await jussiResponse.json(); + if (isObjectEmptyOrNullOrUndefined(jussi)) { + let err_msg = `Server ${server} Invalid jussi response: ${JSON.stringify(jussi)}`; + log(err_msg); + await counters.incrementNotChosen(server); + throw new Error(err_msg); + } + if (jussi["status"] !== "OK") { + let err_msg = `Server ${server} Jussi Status != "OK": ${JSON.stringify(jussi)}`; + log(err_msg); + await counters.incrementNotChosen(server); + throw new Error(err_msg); + } + let jussi_number = jussi["jussi_num"]; + + log(`Server ${server} jussi_number: ${jussi_number}`); + if (typeof jussi_number === "number" && Number.isInteger(jussi_number)) { + jussi_number = parseInt(jussi_number); + } + + if (jussi_number === 20000000) { + let err_msg = `Server ${server} Invalid jussi_number value (20000000): ${jussi_number}`; + await counters.incrementJussiBehind(server); + throw new Error(err_msg); + } + + await counters.updateMaxJussi(jussi_number); + + if (counters.maxJussi > jussi_number + maxJussiNumberDiff) { + let err_msg = `Server ${server} is too far behind: jussi_number ${jussi_number} vs latest ${counters.maxJussi} - diff ${counters.maxJussi - jussi_number}`; + log(err_msg); + await counters.incrementJussiBehind(server); + throw new Error(err_msg); + } + + log( + `Tested OK: Server ${server} version=${blockchain_version}, jussi_number=${jussi_number}`, + ); + return { server, version: jsonResponse, jussi_number, latencyMs }; + } catch (error) { + let err_msg = `${error.name}: Server ${server} Failed to fetch version from ${server}: ${error.message}`; + log(err_msg); + if (error.name === "AbortError") { + err_msg = `Fetch request to ${server} timed out after ${timeout} ms`; + log(err_msg); + await counters.incrementTimedOut(server); + } + throw new Error(err_msg); + } + }; +} + +export { createGetServerData }; diff --git a/src/index.js b/src/index.js index 2bd91e7..5825b67 100755 --- a/src/index.js +++ b/src/index.js @@ -4,33 +4,27 @@ import bodyParser from "body-parser"; import cors from "cors"; import rateLimit from "express-rate-limit"; import fs from "fs"; -import yaml from "js-yaml"; import path from "path"; import https from "https"; import http from "http"; import compression from "compression"; import helmet from "helmet"; import { StatusCodes } from "http-status-codes"; -import { performance } from "perf_hooks"; import { shuffle, log, - compareVersion, limitStringMaxLength, secondsToTimeDict, isObjectEmptyOrNullOrUndefined, - calculateErrorPercentage, - calculatePercentage, } from "./functions.js"; -import { - fetchWithTimeout, - forwardRequestPOST, - forwardRequestGET, -} from "./network.js"; +import { forwardRequestPOST, forwardRequestGET } from "./network.js"; import { chooseNode, getStrategyByName } from "./choose-node.js"; +import { loadConfig } from "./config.js"; +import { Counters } from "./counters.js"; +import { createGetServerData } from "./health-check.js"; const pLimit = (...args) => import("p-limit").then((module) => module.default(...args)); @@ -43,45 +37,15 @@ const __dirname = path.dirname(__filename); let startTime = new Date(); log(`Current Time: ${startTime.toISOString()}`); -// counters to keep track of the requests -let access_counters = new Map(); -let error_counters = new Map(); -let total_counter = 0; -let not_chosen_counters = new Map(); -let jussi_behind_counters = new Map(); -let timed_out_counters = new Map(); -let current_max_jussi = -1; - -// Mutexes to Update the counters -const mutexJussiNumber = new Mutex(); -const mutexAccessCounter = new Mutex(); -const mutexErrorCounter = new Mutex(); -const mutexTotalCounter = new Mutex(); -const mutexNotChosenCounter = new Mutex(); -const mutexJussiBehindCounter = new Mutex(); -const mutexTimedOutCounter = new Mutex(); -const mutexCacheLastNode = new Mutex(); +// Statistics (counters, mutexes and request-rate tracking). +const counters = new Counters(); -// Initialize queues to store request timestamps -let requestTimestamps = []; +// Mutex guarding the cached "last chosen node" map. +const mutexCacheLastNode = new Mutex(); // Read the YAML config file located one level up from the current directory const configPath = path.join(__dirname, "../config.yaml"); -if (!fs.existsSync(configPath)) { - console.error(`Configuration file not found at ${configPath}`); - process.exit(1); -} - -// Load the YAML file content with environment variable replacement -let config = yaml.load(fs.readFileSync(configPath, "utf8")); - -// Replace environment variables in the loaded config -config = JSON.parse( - JSON.stringify(config).replace( - /\$\{(.+?)\}/g, - (_, name) => process.env[name], - ), -); +const config = loadConfig(configPath); log(`PLimit: ${config.plimit}`); @@ -149,42 +113,14 @@ app.head("/", (req, res, next) => { // Middleware to assume 'Content-Type: application/json' if not provided app.use((req, res, next) => { - const now = Date.now(); - requestTimestamps.push(now); - // Remove timestamps older than 15 minutes (900000 milliseconds) - const cutoffTime = now - 15 * 60 * 1000; - requestTimestamps = requestTimestamps.filter( - (timestamp) => timestamp > cutoffTime, - ); + // Track the request timestamp for requests-per-second statistics. + counters.recordRequest(); // Force JSON parsing for every request req.headers["content-type"] = "application/json"; next(); }); -// Function to calculate RPS for 1, 5, and 15 minutes -function calculateRPS() { - const now = Date.now(); - - const intervals = { - "1min": now - 1 * 60 * 1000, - "5min": now - 5 * 60 * 1000, - "15min": now - 15 * 60 * 1000, - }; - - const rps = {}; - for (const [key, intervalStart] of Object.entries(intervals)) { - const requestsInInterval = requestTimestamps.filter( - (timestamp) => timestamp > intervalStart, - ).length; - rps[key] = parseFloat( - (requestsInInterval / (parseInt(key) * 60)).toFixed(2), - ); // requests per second - } - - return rps; -} - // Configure body-parser to accept larger payloads log(`Max Payload Size = ${config.max_payload_size}`); app.use(bodyParser.json({ limit: config.max_payload_size })); // For JSON payloads @@ -230,190 +166,15 @@ log(`Max Body Length Logging: ${logging_max_body_len}`); log(`Retry for GET and POST forward: ${retry_count}`); log(`Nodes: ${config.nodes}`); -// Fetch version from the server -async function getServerData(server) { - const startTime = performance.now(); // start timer - try { - const versionPromise = fetchWithTimeout( - server, - { - method: "POST", - cache: "no-cache", - mode: "cors", - redirect: "follow", - headers: { - "Content-Type": "application/json", - "User-Agent": user_agent, - }, - body: JSON.stringify({ - id: 0, - jsonrpc: "2.0", - method: "call", - params: ["login_api", "get_version", []], - }), - agent, - }, - timeout, - ); - - const jussiPromise = fetchWithTimeout( - server, - { - method: "GET", - cache: "no-cache", - mode: "cors", - redirect: "follow", - headers: { - "Content-Type": "application/json", - "User-Agent": user_agent, - }, - agent, - }, - timeout, - ); - - // Wait for both fetches to complete - const [ - { response: versionResponse, latency: versionLatency }, - { response: jussiResponse, latency: jussiLatency }, - ] = await Promise.all([versionPromise, jussiPromise]); - - const latencyMs = performance.now() - startTime; // end timer - log( - `Server ${server} Latency: ${latencyMs.toFixed(2)} ms (version: ${versionLatency} ms, jussi: ${jussiLatency} ms)`, - ); - - if (!versionResponse.ok) { - let err_msg = `Server ${server} (version) responded with status: ${versionResponse.status}`; - log(err_msg); - await mutexNotChosenCounter.runExclusive(() => { - not_chosen_counters.set( - server, - (not_chosen_counters.get(server) ?? 0) + 1, - ); - }); - throw new Error(err_msg); - } - - if (!jussiResponse.ok) { - let err_msg = `Server ${server} (jussi_number) responded with status: ${jussiResponse.status}`; - log(err_msg); - await mutexNotChosenCounter.runExclusive(() => { - not_chosen_counters.set( - server, - (not_chosen_counters.get(server) ?? 0) + 1, - ); - }); - throw new Error(err_msg); - } - - const jsonResponse = await versionResponse.json(); - - if ( - isObjectEmptyOrNullOrUndefined(jsonResponse) || - isObjectEmptyOrNullOrUndefined(jsonResponse["result"]) - ) { - let err_msg = `Server ${server} Invalid version response: ${JSON.stringify(jsonResponse)}`; - log(err_msg); - await mutexNotChosenCounter.runExclusive(() => { - not_chosen_counters.set( - server, - (not_chosen_counters.get(server) ?? 0) + 1, - ); - }); - throw new Error(err_msg); - } - - const blockchain_version = jsonResponse["result"]["blockchain_version"]; - if (compareVersion(blockchain_version, min_blockchain_version) == -1) { - let err_msg = `Server ${server} version = ${blockchain_version}: but min version is ${min_blockchain_version}`; - log(err_msg); - await mutexNotChosenCounter.runExclusive(() => { - not_chosen_counters.set( - server, - (not_chosen_counters.get(server) ?? 0) + 1, - ); - }); - throw new Error(err_msg); - } - - const jussi = await jussiResponse.json(); - if (isObjectEmptyOrNullOrUndefined(jussi)) { - let err_msg = `Server ${server} Invalid jussi response: ${JSON.stringify(jussi)}`; - log(err_msg); - await mutexNotChosenCounter.runExclusive(() => { - not_chosen_counters.set( - server, - (not_chosen_counters.get(server) ?? 0) + 1, - ); - }); - throw new Error(err_msg); - } - if (jussi["status"] !== "OK") { - let err_msg = `Server ${server} Jussi Status != "OK": ${JSON.stringify(jussi)}`; - log(err_msg); - await mutexNotChosenCounter.runExclusive(() => { - not_chosen_counters.set( - server, - (not_chosen_counters.get(server) ?? 0) + 1, - ); - }); - throw new Error(err_msg); - } - let jussi_number = jussi["jussi_num"]; - - log(`Server ${server} jussi_number: ${jussi_number}`); - if (typeof jussi_number === "number" && Number.isInteger(jussi_number)) { - jussi_number = parseInt(jussi_number); - } - - if (jussi_number === 20000000) { - let err_msg = `Server ${server} Invalid jussi_number value (20000000): ${jussi_number}`; - await mutexJussiBehindCounter.runExclusive(() => { - jussi_behind_counters.set( - server, - (jussi_behind_counters.get(server) ?? 0) + 1, - ); - }); - throw new Error(err_msg); - } - - await mutexJussiNumber.runExclusive(() => { - current_max_jussi = Math.max(current_max_jussi, jussi_number); - }); - - if (current_max_jussi > jussi_number + max_jussi_number_diff) { - let err_msg = `Server ${server} is too far behind: jussi_number ${jussi_number} vs latest ${current_max_jussi} - diff ${current_max_jussi - jussi_number}`; - log(err_msg); - await mutexJussiBehindCounter.runExclusive(() => { - jussi_behind_counters.set( - server, - (jussi_behind_counters.get(server) ?? 0) + 1, - ); - }); - throw new Error(err_msg); - } - - log( - `Tested OK: Server ${server} version=${blockchain_version}, jussi_number=${jussi_number}`, - ); - return { server, version: jsonResponse, jussi_number, latencyMs }; - } catch (error) { - let err_msg = `${error.name}: Server ${server} Failed to fetch version from ${server}: ${error.message}`; - log(err_msg); - if (error.name === "AbortError") { - err_msg = `Fetch request to ${server} timed out after ${timeout} ms`; - log(err_msg); - await mutexTimedOutCounter.runExclusive(() => { - timed_out_counters.set( - server, - (timed_out_counters.get(server) ?? 0) + 1, - ); - }); - } - throw new Error(err_msg); - } -} +// Health-check function bound to the runtime configuration and counters. +const getServerData = createGetServerData({ + agent, + timeout, + userAgent: user_agent, + minBlockchainVersion: min_blockchain_version, + maxJussiNumberDiff: max_jussi_number_diff, + counters, +}); // Handle incoming requests app.all("/", async (req, res) => { @@ -442,22 +203,6 @@ app.all("/", async (req, res) => { const promises = shuffledNodes.map((node) => plimit(() => getServerData(node)), ); - // chosenNode = await Promise.any(promises).catch((error) => { - // log(`Error: ${error.message}`); - // return null; - // }); - - // const fulfilledNodes = await firstKFulfilled(promises, firstK); - // if (fulfilledNodes.length === 0) { - // log("No valid nodes found after checking all nodes."); - // res - // .status(StatusCodes.INTERNAL_SERVER_ERROR) - // .json({ error: "No valid nodes available" }); - // return; - // } - // choose the node with the highest jussi_number - //fulfilledNodes.sort((a, b) => b.jussi_number - a.jussi_number); - //chosenNode = fulfilledNodes[0]; const result = await chooseNode(promises, firstK, strategy).catch( (error) => { @@ -499,7 +244,7 @@ app.all("/", async (req, res) => { log( `Request: ${ip}, ${method}: Chosen Node (version=${chosenNode.version["result"]["blockchain_version"]}): ${chosenNode.server} - jussi_number: ${chosenNode.jussi_number}`, ); - log(`Current Max Jussi: ${current_max_jussi}`); + log(`Current Max Jussi: ${counters.maxJussi}`); res.setHeader("IP", ip); res.setHeader("Server", chosenNode.server); if (typeof chosenNode.version !== "undefined") { @@ -517,15 +262,8 @@ app.all("/", async (req, res) => { let result; // update stats - await mutexTotalCounter.runExclusive(() => { - total_counter++; - }); - await mutexAccessCounter.runExclusive(() => { - access_counters.set( - chosenNode.server, - (access_counters.get(chosenNode.server) ?? 0) + 1, - ); - }); + await counters.incrementTotal(); + await counters.incrementAccess(chosenNode.server); let currentDate = new Date(); let differenceInSeconds = Math.floor((currentDate - startTime) / 1000); const diff = secondsToTimeDict(differenceInSeconds); @@ -578,12 +316,7 @@ app.all("/", async (req, res) => { } log(`Error forwarding request to ${chosenNode.server}: ${ex.message}`); // set error counters - this is after max-retry - await mutexErrorCounter.runExclusive(() => { - error_counters.set( - chosenNode.server, - (error_counters.get(chosenNode.server) ?? 0) + 1, - ); - }); + await counters.incrementError(chosenNode.server); } if (method === "GET") { data["__server__"] = chosenNode.server; @@ -605,10 +338,10 @@ app.all("/", async (req, res) => { data["__first_k_candidates__"] = candidates; data["__load_balancer_version__"] = proxy_version; // Calculate and include RPS stats - const rpsStats = calculateRPS(); + const rpsStats = counters.calculateRPS(); data["__stats__"] = { - total: total_counter, - rps: parseFloat((total_counter / differenceInSeconds).toFixed(2)), + total: counters.total, + rps: parseFloat((counters.total / differenceInSeconds).toFixed(2)), rps_stats: { "1min": rpsStats["1min"], "5min": rpsStats["5min"], @@ -629,11 +362,11 @@ app.all("/", async (req, res) => { month: diff.months, year: diff.years, }, - access_counters: calculatePercentage(access_counters, total_counter), - error_counters: calculateErrorPercentage(error_counters, access_counters), - not_chosen_counters: Object.fromEntries(not_chosen_counters), - jussi_behind_counters: Object.fromEntries(jussi_behind_counters), - timed_out_counters: Object.fromEntries(timed_out_counters), + access_counters: counters.getAccessPercentages(), + error_counters: counters.getErrorPercentages(), + not_chosen_counters: counters.getNotChosen(), + jussi_behind_counters: counters.getJussiBehind(), + timed_out_counters: counters.getTimedOut(), }; } if (isObjectEmptyOrNullOrUndefined(result)) { From f09dbe128c12e95781e857bb9358a62bfd75ada2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:00:11 +0000 Subject: [PATCH 2/3] Fix AbortError detection, guard zero uptime RPS, add health-check tests --- js_tests/health-check.test.js | 205 ++++++++++++++++++ package-lock.json | 382 ++++++++++++++++++++++++---------- src/health-check.js | 5 +- src/index.js | 2 +- 4 files changed, 481 insertions(+), 113 deletions(-) create mode 100644 js_tests/health-check.test.js diff --git a/js_tests/health-check.test.js b/js_tests/health-check.test.js new file mode 100644 index 0000000..5d19351 --- /dev/null +++ b/js_tests/health-check.test.js @@ -0,0 +1,205 @@ +import { createGetServerData } from "../src/health-check.js"; + +jest.mock("../src/network.js", () => ({ + fetchWithTimeout: jest.fn(), +})); +jest.mock("../src/functions.js", () => { + const actual = jest.requireActual("../src/functions.js"); + return { ...actual, log: jest.fn() }; +}); + +import { fetchWithTimeout } from "../src/network.js"; +import { Counters } from "../src/counters.js"; + +const SERVER = "https://node.example.com"; +const TIMEOUT = 5000; +const MIN_VERSION = "0.23.0"; +const MAX_JUSSI_DIFF = 100; + +function makeCounters() { + const counters = new Counters(); + jest.spyOn(counters, "incrementNotChosen"); + jest.spyOn(counters, "incrementJussiBehind"); + jest.spyOn(counters, "incrementTimedOut"); + jest.spyOn(counters, "updateMaxJussi"); + return counters; +} + +function makeGetServerData(counters) { + return createGetServerData({ + agent: false, + timeout: TIMEOUT, + userAgent: "jest-agent", + minBlockchainVersion: MIN_VERSION, + maxJussiNumberDiff: MAX_JUSSI_DIFF, + counters, + }); +} + +function makeOkVersionResponse(version = "0.23.1") { + return { + ok: true, + json: jest.fn().mockResolvedValue({ + result: { blockchain_version: version }, + }), + }; +} + +function makeOkJussiResponse(jussiNum = 1000200) { + return { + ok: true, + json: jest.fn().mockResolvedValue({ + status: "OK", + jussi_num: jussiNum, + }), + }; +} + +describe("createGetServerData", () => { + let counters; + let getServerData; + + beforeEach(() => { + jest.clearAllMocks(); + counters = makeCounters(); + getServerData = makeGetServerData(counters); + }); + + test("returns server info on a fully successful probe", async () => { + fetchWithTimeout + .mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 10 }) + .mockResolvedValueOnce({ response: makeOkJussiResponse(), latency: 8 }); + + const result = await getServerData(SERVER); + + expect(result.server).toBe(SERVER); + expect(result.jussi_number).toBe(1000200); + expect(typeof result.latencyMs).toBe("number"); + expect(counters.incrementTimedOut).not.toHaveBeenCalled(); + expect(counters.incrementNotChosen).not.toHaveBeenCalled(); + expect(counters.incrementJussiBehind).not.toHaveBeenCalled(); + }); + + test("increments notChosen and throws when version response is not ok", async () => { + fetchWithTimeout + .mockResolvedValueOnce({ + response: { ok: false, status: 503 }, + latency: 5, + }) + .mockResolvedValueOnce({ response: makeOkJussiResponse(), latency: 5 }); + + await expect(getServerData(SERVER)).rejects.toThrow(); + expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER); + }); + + test("increments notChosen and throws when jussi response is not ok", async () => { + fetchWithTimeout + .mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 5 }) + .mockResolvedValueOnce({ + response: { ok: false, status: 503 }, + latency: 5, + }); + + await expect(getServerData(SERVER)).rejects.toThrow(); + expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER); + }); + + test("increments notChosen and throws on empty version response", async () => { + const versionResponse = { ok: true, json: jest.fn().mockResolvedValue({}) }; + fetchWithTimeout + .mockResolvedValueOnce({ response: versionResponse, latency: 5 }) + .mockResolvedValueOnce({ response: makeOkJussiResponse(), latency: 5 }); + + await expect(getServerData(SERVER)).rejects.toThrow(); + expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER); + }); + + test("increments notChosen and throws when blockchain version is too low", async () => { + fetchWithTimeout + .mockResolvedValueOnce({ + response: makeOkVersionResponse("0.22.0"), + latency: 5, + }) + .mockResolvedValueOnce({ response: makeOkJussiResponse(), latency: 5 }); + + await expect(getServerData(SERVER)).rejects.toThrow(); + expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER); + }); + + test("increments notChosen and throws on empty jussi response", async () => { + const jussiResponse = { ok: true, json: jest.fn().mockResolvedValue(null) }; + fetchWithTimeout + .mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 5 }) + .mockResolvedValueOnce({ response: jussiResponse, latency: 5 }); + + await expect(getServerData(SERVER)).rejects.toThrow(); + expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER); + }); + + test("increments notChosen and throws when jussi status is not OK", async () => { + const jussiResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ status: "ERROR", jussi_num: 1000200 }), + }; + fetchWithTimeout + .mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 5 }) + .mockResolvedValueOnce({ response: jussiResponse, latency: 5 }); + + await expect(getServerData(SERVER)).rejects.toThrow(); + expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER); + }); + + test("increments jussiBehind and throws when jussi_number is 20000000", async () => { + fetchWithTimeout + .mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 5 }) + .mockResolvedValueOnce({ + response: makeOkJussiResponse(20000000), + latency: 5, + }); + + await expect(getServerData(SERVER)).rejects.toThrow(); + expect(counters.incrementJussiBehind).toHaveBeenCalledWith(SERVER); + expect(counters.incrementNotChosen).not.toHaveBeenCalled(); + }); + + test("increments jussiBehind and throws when node is too far behind", async () => { + // Set maxJussi to a high value so the node appears behind + await counters.updateMaxJussi(1001000); + // jussi_number is more than MAX_JUSSI_DIFF behind maxJussi + fetchWithTimeout + .mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 5 }) + .mockResolvedValueOnce({ + response: makeOkJussiResponse(1000000 - MAX_JUSSI_DIFF - 1), + latency: 5, + }); + + await expect(getServerData(SERVER)).rejects.toThrow(); + expect(counters.incrementJussiBehind).toHaveBeenCalledWith(SERVER); + }); + + test("increments timedOut when fetchWithTimeout throws a timeout error (message includes 'timed out')", async () => { + const timeoutErr = new Error( + `Fetch request to ${SERVER} timed out after ${TIMEOUT} ms`, + ); + fetchWithTimeout.mockRejectedValue(timeoutErr); + + await expect(getServerData(SERVER)).rejects.toThrow(); + expect(counters.incrementTimedOut).toHaveBeenCalledWith(SERVER); + }); + + test("increments timedOut when fetchWithTimeout throws an AbortError", async () => { + const abortErr = new Error("The operation was aborted"); + abortErr.name = "AbortError"; + fetchWithTimeout.mockRejectedValue(abortErr); + + await expect(getServerData(SERVER)).rejects.toThrow(); + expect(counters.incrementTimedOut).toHaveBeenCalledWith(SERVER); + }); + + test("does not increment timedOut for generic network errors", async () => { + fetchWithTimeout.mockRejectedValue(new Error("Network failure")); + + await expect(getServerData(SERVER)).rejects.toThrow("Network failure"); + expect(counters.incrementTimedOut).not.toHaveBeenCalled(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 40229bb..c3b9edc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,7 +77,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1701,6 +1700,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -1718,6 +1718,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1735,6 +1736,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1752,6 +1754,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1769,6 +1772,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1786,6 +1790,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1803,6 +1808,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1820,6 +1826,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1837,6 +1844,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1854,6 +1862,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1871,6 +1880,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1888,6 +1898,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1905,6 +1916,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1922,6 +1934,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1939,6 +1952,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1956,6 +1970,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1973,6 +1988,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1990,6 +2006,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2007,6 +2024,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2024,6 +2042,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2041,6 +2060,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2058,6 +2078,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -2075,6 +2096,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -2092,6 +2114,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2109,6 +2132,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2126,6 +2150,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2845,7 +2870,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.52.5", @@ -2859,7 +2885,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.52.5", @@ -2873,7 +2900,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.52.5", @@ -2887,7 +2915,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.52.5", @@ -2901,7 +2930,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.52.5", @@ -2915,7 +2945,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.52.5", @@ -2929,7 +2960,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.52.5", @@ -2943,7 +2975,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.52.5", @@ -2957,7 +2990,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.52.5", @@ -2971,7 +3005,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.52.5", @@ -2985,7 +3020,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.52.5", @@ -2999,7 +3035,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.52.5", @@ -3013,7 +3050,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.52.5", @@ -3027,7 +3065,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.52.5", @@ -3041,7 +3080,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.52.5", @@ -3055,7 +3095,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.52.5", @@ -3069,7 +3110,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.52.5", @@ -3083,7 +3125,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.52.5", @@ -3097,7 +3140,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.52.5", @@ -3111,7 +3155,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.52.5", @@ -3125,7 +3170,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.52.5", @@ -3139,7 +3185,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@sinclair/typebox": { "version": "0.27.8", @@ -3217,6 +3264,7 @@ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -3227,7 +3275,8 @@ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/estree": { "version": "1.0.8", @@ -3279,7 +3328,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.1.tgz", "integrity": "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==", "dev": true, - "peer": true, "dependencies": { "undici-types": "~7.13.0" } @@ -3432,6 +3480,7 @@ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", @@ -3449,6 +3498,7 @@ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", @@ -3476,6 +3526,7 @@ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tinyrainbow": "^2.0.0" }, @@ -3489,6 +3540,7 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -3504,6 +3556,7 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -3519,6 +3572,7 @@ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tinyspy": "^4.0.3" }, @@ -3532,6 +3586,7 @@ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", @@ -3577,7 +3632,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3673,6 +3727,7 @@ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" } @@ -3913,7 +3968,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3957,6 +4011,7 @@ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -4032,6 +4087,7 @@ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -4074,6 +4130,7 @@ "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 16" } @@ -4339,6 +4396,7 @@ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4482,7 +4540,8 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -4502,6 +4561,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4568,7 +4628,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4877,6 +4936,7 @@ "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=12.0.0" } @@ -4885,7 +4945,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -6421,7 +6480,8 @@ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lru-cache": { "version": "5.1.1", @@ -6603,6 +6663,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -6921,7 +6982,8 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pathval": { "version": "2.0.1", @@ -6929,6 +6991,7 @@ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 14.16" } @@ -6992,6 +7055,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7276,6 +7340,7 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7494,7 +7559,8 @@ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/signal-exit": { "version": "3.0.7", @@ -7578,7 +7644,8 @@ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/statuses": { "version": "2.0.2", @@ -7700,6 +7767,7 @@ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^9.0.1" }, @@ -7712,7 +7780,8 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/supports-color": { "version": "7.2.0", @@ -7757,14 +7826,16 @@ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -7772,6 +7843,7 @@ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -7789,6 +7861,7 @@ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.0.0" }, @@ -7821,6 +7894,7 @@ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.0.0 || >=20.0.0" } @@ -7841,6 +7915,7 @@ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -8125,6 +8200,7 @@ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", @@ -8148,6 +8224,7 @@ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.0.0" }, @@ -8180,6 +8257,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -8253,6 +8331,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8306,6 +8385,7 @@ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" @@ -8467,7 +8547,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, - "peer": true, "requires": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -9555,182 +9634,208 @@ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/android-arm": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/android-arm64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/android-x64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/darwin-arm64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/darwin-x64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/freebsd-arm64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/freebsd-x64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-arm": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-arm64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-ia32": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-loong64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-mips64el": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-ppc64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-riscv64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-s390x": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-x64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/netbsd-arm64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/netbsd-x64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/openbsd-arm64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/openbsd-x64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/openharmony-arm64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/sunos-x64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/win32-arm64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/win32-ia32": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/win32-x64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@eslint-community/eslint-utils": { "version": "4.9.0", @@ -10256,154 +10361,176 @@ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-android-arm64": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-darwin-arm64": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-darwin-x64": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-freebsd-arm64": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-freebsd-x64": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-linux-arm-gnueabihf": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-linux-arm-musleabihf": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-linux-arm64-gnu": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-linux-arm64-musl": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-linux-loong64-gnu": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-linux-ppc64-gnu": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-linux-riscv64-gnu": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-linux-riscv64-musl": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-linux-s390x-gnu": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-linux-x64-gnu": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-linux-x64-musl": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-openharmony-arm64": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-win32-arm64-msvc": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-win32-ia32-msvc": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-win32-x64-gnu": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@rollup/rollup-win32-x64-msvc": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@sinclair/typebox": { "version": "0.27.8", @@ -10480,6 +10607,7 @@ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, + "peer": true, "requires": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -10489,7 +10617,8 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true + "dev": true, + "peer": true }, "@types/estree": { "version": "1.0.8", @@ -10541,7 +10670,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.1.tgz", "integrity": "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==", "dev": true, - "peer": true, "requires": { "undici-types": "~7.13.0" } @@ -10655,6 +10783,7 @@ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, + "peer": true, "requires": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", @@ -10668,6 +10797,7 @@ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, + "peer": true, "requires": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", @@ -10679,6 +10809,7 @@ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, + "peer": true, "requires": { "tinyrainbow": "^2.0.0" } @@ -10688,6 +10819,7 @@ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, + "peer": true, "requires": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -10699,6 +10831,7 @@ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, + "peer": true, "requires": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -10710,6 +10843,7 @@ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, + "peer": true, "requires": { "tinyspy": "^4.0.3" } @@ -10719,6 +10853,7 @@ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, + "peer": true, "requires": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", @@ -10753,8 +10888,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "peer": true + "dev": true }, "acorn-jsx": { "version": "5.3.2", @@ -10818,7 +10952,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true + "dev": true, + "peer": true }, "ast-v8-to-istanbul": { "version": "0.3.7", @@ -11002,7 +11137,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -11035,7 +11169,8 @@ "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true + "dev": true, + "peer": true }, "call-bind-apply-helpers": { "version": "1.0.2", @@ -11078,6 +11213,7 @@ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, + "peer": true, "requires": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -11106,7 +11242,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true + "dev": true, + "peer": true }, "ci-info": { "version": "3.9.0", @@ -11298,7 +11435,8 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true + "dev": true, + "peer": true }, "deep-is": { "version": "0.1.4", @@ -11401,7 +11539,8 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true + "dev": true, + "peer": true }, "es-object-atoms": { "version": "1.1.1", @@ -11416,6 +11555,7 @@ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", "dev": true, + "peer": true, "requires": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", @@ -11467,7 +11607,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11673,13 +11812,13 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", - "dev": true + "dev": true, + "peer": true }, "express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "peer": true, "requires": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -12809,7 +12948,8 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true + "dev": true, + "peer": true }, "lru-cache": { "version": "5.1.1", @@ -12940,7 +13080,8 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true + "dev": true, + "peer": true }, "natural-compare": { "version": "1.4.0", @@ -13159,13 +13300,15 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true + "dev": true, + "peer": true }, "pathval": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true + "dev": true, + "peer": true }, "picocolors": { "version": "1.1.1", @@ -13199,6 +13342,7 @@ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, + "peer": true, "requires": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13394,6 +13538,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, + "peer": true, "requires": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", @@ -13546,7 +13691,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true + "dev": true, + "peer": true }, "signal-exit": { "version": "3.0.7", @@ -13615,7 +13761,8 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true + "dev": true, + "peer": true }, "statuses": { "version": "2.0.2", @@ -13701,6 +13848,7 @@ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, + "peer": true, "requires": { "js-tokens": "^9.0.1" }, @@ -13709,7 +13857,8 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true + "dev": true, + "peer": true } } }, @@ -13743,19 +13892,22 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true + "dev": true, + "peer": true }, "tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true + "dev": true, + "peer": true }, "tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, + "peer": true, "requires": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -13766,6 +13918,7 @@ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, + "peer": true, "requires": {} }, "picomatch": { @@ -13781,7 +13934,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true + "dev": true, + "peer": true }, "tinyrainbow": { "version": "2.0.0", @@ -13793,7 +13947,8 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true + "dev": true, + "peer": true }, "tmpl": { "version": "1.0.5", @@ -13951,6 +14106,7 @@ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, + "peer": true, "requires": {} }, "picomatch": { @@ -13967,6 +14123,7 @@ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, + "peer": true, "requires": { "cac": "^6.7.14", "debug": "^4.4.1", @@ -13980,6 +14137,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, + "peer": true, "requires": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -14010,7 +14168,8 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true + "dev": true, + "peer": true } } }, @@ -14051,6 +14210,7 @@ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, + "peer": true, "requires": { "siginfo": "^2.0.0", "stackback": "0.0.2" diff --git a/src/health-check.js b/src/health-check.js index bd7cd81..83e6c3c 100644 --- a/src/health-check.js +++ b/src/health-check.js @@ -147,7 +147,10 @@ function createGetServerData({ } catch (error) { let err_msg = `${error.name}: Server ${server} Failed to fetch version from ${server}: ${error.message}`; log(err_msg); - if (error.name === "AbortError") { + if ( + error.name === "AbortError" || + error.message.includes("timed out") + ) { err_msg = `Fetch request to ${server} timed out after ${timeout} ms`; log(err_msg); await counters.incrementTimedOut(server); diff --git a/src/index.js b/src/index.js index 5825b67..acb327f 100755 --- a/src/index.js +++ b/src/index.js @@ -341,7 +341,7 @@ app.all("/", async (req, res) => { const rpsStats = counters.calculateRPS(); data["__stats__"] = { total: counters.total, - rps: parseFloat((counters.total / differenceInSeconds).toFixed(2)), + rps: differenceInSeconds > 0 ? parseFloat((counters.total / differenceInSeconds).toFixed(2)) : 0, rps_stats: { "1min": rpsStats["1min"], "5min": rpsStats["5min"], From 839ada809ad73f858384be1fed5235ec5cbb90c1 Mon Sep 17 00:00:00 2001 From: Zhihua Lai Date: Thu, 11 Jun 2026 12:01:21 +0100 Subject: [PATCH 3/3] Format --- js_tests/health-check.test.js | 4 +++- src/health-check.js | 5 +---- src/index.js | 5 ++++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/js_tests/health-check.test.js b/js_tests/health-check.test.js index 5d19351..2db3393 100644 --- a/js_tests/health-check.test.js +++ b/js_tests/health-check.test.js @@ -139,7 +139,9 @@ describe("createGetServerData", () => { test("increments notChosen and throws when jussi status is not OK", async () => { const jussiResponse = { ok: true, - json: jest.fn().mockResolvedValue({ status: "ERROR", jussi_num: 1000200 }), + json: jest + .fn() + .mockResolvedValue({ status: "ERROR", jussi_num: 1000200 }), }; fetchWithTimeout .mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 5 }) diff --git a/src/health-check.js b/src/health-check.js index 83e6c3c..b96ac0b 100644 --- a/src/health-check.js +++ b/src/health-check.js @@ -147,10 +147,7 @@ function createGetServerData({ } catch (error) { let err_msg = `${error.name}: Server ${server} Failed to fetch version from ${server}: ${error.message}`; log(err_msg); - if ( - error.name === "AbortError" || - error.message.includes("timed out") - ) { + if (error.name === "AbortError" || error.message.includes("timed out")) { err_msg = `Fetch request to ${server} timed out after ${timeout} ms`; log(err_msg); await counters.incrementTimedOut(server); diff --git a/src/index.js b/src/index.js index acb327f..3657555 100755 --- a/src/index.js +++ b/src/index.js @@ -341,7 +341,10 @@ app.all("/", async (req, res) => { const rpsStats = counters.calculateRPS(); data["__stats__"] = { total: counters.total, - rps: differenceInSeconds > 0 ? parseFloat((counters.total / differenceInSeconds).toFixed(2)) : 0, + rps: + differenceInSeconds > 0 + ? parseFloat((counters.total / differenceInSeconds).toFixed(2)) + : 0, rps_stats: { "1min": rpsStats["1min"], "5min": rpsStats["5min"],