diff --git a/.codebeatignore b/.codebeatignore deleted file mode 100644 index 3669b72..0000000 --- a/.codebeatignore +++ /dev/null @@ -1 +0,0 @@ -benchmark/** diff --git a/.codebeatsettings b/.codebeatsettings deleted file mode 100644 index 84f6f2a..0000000 --- a/.codebeatsettings +++ /dev/null @@ -1,7 +0,0 @@ -{ - "TYPESCRIPT": { - "TOTAL_LOC": [500, 1000, 1500, 2000], - "TOO_MANY_FUNCTIONS": [40, 50, 60, 70], - "TOTAL_COMPLEXITY": [100, 180, 280, 400] - } -} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 67bcd75..c564f7b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,16 +2,49 @@ name: Build on: push: + branches: [master] + tags: ['*'] pull_request: +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build: + quality: + name: Lint & format runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npm run format:check + - name: Lint + run: npm run lint + + test: + name: Test (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + timeout-minutes: 15 strategy: + fail-fast: false matrix: - node-version: [lts/*] - + node-version: [22.x, 24.x] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -20,9 +53,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + cache: npm - name: Install dependencies - run: npm install + run: npm ci - name: Run tests run: npm test diff --git a/.gitignore b/.gitignore index e04899e..229818a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ tmp/ out-tsc/ build/ .nyc_output/ +.agent-out/ node_modules/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..16d1869 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,9 @@ +{ + "singleQuote": true, + "tabWidth": 4, + "printWidth": 80, + "semi": true, + "trailingComma": "all", + "arrowParens": "avoid", + "bracketSpacing": true +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..2757f3e --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,20 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "error" + }, + "rules": { + "no-debugger": "error", + "no-console": ["warn", { "allow": ["warn", "error", "info"] }], + "no-unused-vars": "warn" + }, + "ignorePatterns": [ + "**/*.js", + "**/*.d.ts", + "docs/", + "coverage/", + ".nyc_output/", + ".agent-out/", + "node_modules/" + ] +} diff --git a/README.md b/README.md index b11ca5f..d33e73a 100644 --- a/README.md +++ b/README.md @@ -2,63 +2,63 @@ [![License](https://img.shields.io/badge/license-GPL-blue.svg)](https://rawgit.com/imqueue/core/master/LICENSE) -Simple JSON-based messaging queue for inter service communication +Simple JSON-based messaging queue for inter-service communication **Related packages:** - - [@imqueue/rpc](https://github.com/imqueue/rpc) - RPC-like client/service - implementation over @imqueue/core. - - [@imqueue/cli](https://github.com/imqueue/cli) - Command Line Interface - for imqueue. +- [@imqueue/rpc](https://github.com/imqueue/rpc) - RPC-like client/service + implementation over @imqueue/core. +- [@imqueue/cli](https://github.com/imqueue/cli) - Command Line Interface + for imqueue. # Features With current implementation on RedisQueue: - - **Fast unwarranted** message delivery (if consumer grab the message and dies - message will be lost). Up to ~35-40k of 1Kb messages per second on i7 core by - benchmarks. - - **Fast warranted** message delivery (only 1.5-2 times slower than - unwarranted). If consumer grab a message and dies it will be re-scheduled - in queue. Up to ~20-25K of 1Kb messages per second on i7 core by benchmarks. - - **No timers or constant redis polling** used for implementation, as result - - no delays in delivery and low CPU usage on application workers. When idling - it does not consume resources! - - **Supports gzip compression for messages** (decrease traffic usage, but - slower). - - **Concurrent workers model supported**, the same queue can have multiple - consumers. - - **Delayed messages supported**, fast as ~10K of 1Kb messages per second on i7 - core by benchmarks. - - **Safe predictable scaling of queues**. Scaling number of workers does not - influence traffic usage. - - **Round-robin message balancing between several redis instances**. This - allows easy messaging queue redis horizontal scaling. - - **TypeScript included!** +- **Fast, unreliable** message delivery (if a consumer grabs the message and dies, + the message will be lost). Up to ~35–40k of 1Kb messages per second on an i7 core + by benchmarks. +- **Fast, guaranteed** message delivery (only 1.5–2 times slower than + unreliable mode). If a consumer grabs a message and dies, it will be rescheduled + to the queue. Up to ~20–25k of 1Kb messages per second on an i7 core by benchmarks. +- **No timers or constant Redis polling** used for implementation, resulting in + no delays in delivery and low CPU usage on application workers. When idle, + it consumes no resources. +- **Supports Gzip compression for messages** (decreases traffic usage but is slower). +- **Concurrent workers model supported**, the same queue can have multiple + consumers. +- **Delayed messages supported**, fast as ~10K of 1Kb messages per second on i7 + core by benchmarks. +- **Safe, predictable scaling of queues**. Scaling the number of workers does not + increase traffic usage. +- **Round-robin message balancing between multiple Redis instances**. This + allows easy horizontal scaling of the messaging queue across Redis instances. +- **TypeScript included!** # Requirements -Currently this module have only one available adapter which is Redis server -related. So redis-server > 3.8+ is required. +Currently this module has only one available adapter, which is Redis. Redis server +6.2+ is required (the queue relies on `LMOVE`/`BLMOVE` commands for safe message +delivery). -If config command is disabled on redis it will be required to turn on manually -keyspace notification events (actual on use with ElasticCache on AWS), like: +If the config command is disabled on Redis, you must manually enable keyspace +notification events (particularly when using AWS ElasticCache), like this: -~~~ +``` notify-keyspace-events Ex -~~~ +``` -Further, more adapters will be added... if needed. +More adapters will be added in the future as needed. # Install -~~~bash +```bash npm i --save @imqueue/core -~~~ +``` # Usage -~~~typescript +```typescript import IMQ, { IMessageQueue, IJson } from '@imqueue/core'; (async () => { @@ -90,27 +90,27 @@ import IMQ, { IMessageQueue, IJson } from '@imqueue/core'; const delay = 1000; await queueOne.send('QueueOne', { delay }, delay); })(); -~~~ +``` # Benchmarking -First of all make sure redis-server is running on the localhost. Current -version of benchmark supposed to have redis running localhost because -it is going to measure it's CPU usage stats. +First, make sure redis-server is running on localhost. The current version of the +benchmark requires Redis to be running on localhost so it can measure its CPU +usage statistics. -All workers during benchmark test will have their dedicated CPU affinity -to make sure collected stats as accurate as possible. +All workers during the benchmark test will have dedicated CPU affinity to ensure +the collected statistics are as accurate as possible. -~~~bash -git clone git@github.com:Mikhus/core.git -cd imq +```bash +git clone git@github.com:imqueue/core.git +cd core node benchmark -c 4 -m 10000 -~~~ +``` Other possible benchmark options: -~~~ -node benchmark -h +``` +node benchmark -h Options: --version Show version number [boolean] -h, --help Show help [boolean] @@ -128,30 +128,40 @@ Options: -p, --port Redis server port to connect to. -t, --message-multiply-times Increase sample message data given number of times. -~~~ +``` -Number of child workers running message queues are limited to a max number -of CPU in the system -2. First one, which is CPU0 is reserved for OS tasks and -for stats collector process. Second, which is CPU1 is dedicated to redis -process running on a local machine, All others are safe to run queue workers. +The number of child workers running message queues is limited to the number of CPUs +in the system minus 2. The first CPU (CPU0) is reserved for OS tasks and the stats +collector process. The second CPU (CPU1) is dedicated to the local Redis process. +All others are available to run queue workers. -For example, if there is 8 cores on a machine it is safe to run up to 6 workers. -For 4-core machine this number is limited to 2. -If there is less cores results will not give good visibility of load. +For example, on an 8-core machine you can safely run up to 6 workers. On a 4-core +machine, this limit is 2 workers. If there are fewer cores, the results will not +provide good visibility of the load. -*NOTE: paragraphs above this note are Linux-only related! On MacOS -there is no good way to set process affinity to a CPU core, Windows support is -not tested and is missing for the moment in benchmarking. I does not mean -benchmark will not work on Mac & Win but the results won't be accurate and -predictable.* +**NOTE:** The paragraphs above apply to Linux only. On macOS there is no reliable way +to set process CPU affinity, and Windows support is not currently implemented for +benchmarking. This does not mean the benchmark won't work on macOS or Windows, but +the results will not be accurate or predictable on those platforms. ## Running Unit Tests -~~~bash +Tests run on the native Node.js test runner (`node:test`) with `node:assert` and +no external test framework, so a plain clone and install is all that is needed: + +```bash git clone git@github.com:imqueue/core.git -cd imq +cd core +npm install npm test -~~~ +``` + +To produce a coverage report use: + +```bash +npm run test-coverage # prints coverage summary to the console +npm run test-lcov # writes coverage/lcov.info +``` ## License diff --git a/benchmark/affinity.ts b/benchmark/affinity.ts index 06cb7e3..6368362 100644 --- a/benchmark/affinity.ts +++ b/benchmark/affinity.ts @@ -21,11 +21,11 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import { execSync as exec } from 'child_process'; -import * as os from 'os'; +import { execSync as exec } from 'node:child_process'; +import { platform } from 'node:os'; export function setAffinity(cpu: number) { - if (os.platform() === 'linux') { + if (platform() === 'linux') { exec(`taskset -cp ${cpu} ${process.pid}`); } -} \ No newline at end of file +} diff --git a/benchmark/index.ts b/benchmark/index.ts index 3276452..f2c49dd 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -21,84 +21,141 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import { execSync as exec } from 'child_process'; -import * as os from 'os'; -import * as fs from 'fs'; -import * as yargs from 'yargs'; +import cluster from 'node:cluster'; +import { execSync as exec } from 'node:child_process'; +import { arch, cpus, freemem, platform, totalmem } from 'node:os'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { parseArgs } from 'node:util'; import { run } from './redis-test'; -import { resolve } from 'path'; -import { uuid, AnyJson } from '..'; +import { resolve } from 'node:path'; +import { randomUUID as uuid } from 'node:crypto'; +import { AnyJson } from '..'; import { setAffinity } from './affinity'; -const cluster: any = require('cluster'); +/** + * Command line options: canonical long name -> parseArgs config and description + * used to render `--help`. + */ +const OPTIONS: { + [name: string]: { + type: 'string' | 'boolean'; + short: string; + describe: string; + }; +} = { + help: { + type: 'boolean', + short: 'h', + describe: 'Show this help.', + }, + children: { + type: 'string', + short: 'c', + describe: 'Number of children test process to fork.', + }, + delay: { + type: 'string', + short: 'd', + describe: + 'Number of milliseconds to delay message delivery for ' + + 'delayed messages. By default delayed messages is off and this ' + + 'argument is equal to 0.', + }, + messages: { + type: 'string', + short: 'm', + describe: + 'Number of messages to be sent by a child process ' + + 'during test execution.', + }, + gzip: { + type: 'boolean', + short: 'z', + describe: 'Use gzip for message encoding/decoding.', + }, + safe: { + type: 'boolean', + short: 's', + describe: 'Use safe (guaranteed) message delivery algorithm.', + }, + 'example-message': { + type: 'string', + short: 'e', + describe: + 'Path to a file containing JSON of example message to ' + + 'use during the tests.', + }, + port: { + type: 'string', + short: 'p', + describe: 'Redis server port to connect to.', + }, + 'message-multiply-times': { + type: 'string', + short: 't', + describe: + 'Increase sample message data given number of times per ' + + 'request.', + }, +}; /** * Command line args - * @type {yargs.Arguments} */ -const ARGV: any = yargs -.help('h') -.alias('h', 'help') - -.alias('c', 'children') -.describe('c', 'Number of children test process to fork') - -.alias('d', 'delay') -.describe('d', 'Number of milliseconds to delay message delivery for ' + - 'delayed messages. By default delayed messages is of and this ' + - 'argument is equal to 0.') - -.alias('m', 'messages') -.describe('m', 'Number of messages to be sent by a child process ' + - 'during test execution.') - -.alias('z', 'gzip') -.describe('z', 'Use gzip for message encoding/decoding.') - -.alias('s', 'safe') -.describe('s', 'Use safe (guaranteed) message delivery algorithm.') - -.alias('e', 'example-message') -.describe('e', 'Path to a file containing JSON of example message to ' + - 'use during the tests.') - -.alias('p', 'port') -.describe('p', 'Redis server port to connect to.') - -.alias('t', 'message-multiply-times') -.describe('t', 'Increase sample message data given number of times per ' + - 'request.') - -.boolean(['h', 'z', 's']) - .argv; +const ARGV: any = parseArgs({ + options: Object.fromEntries( + Object.entries(OPTIONS).map(([name, { type, short }]) => [ + name, + { type, short }, + ]), + ) as any, + allowPositionals: true, +}).values; + +if (ARGV.help) { + const help = Object.entries(OPTIONS) + .map(([name, o]) => ` -${o.short}, --${name}\n ${o.describe}`) + .join('\n\n'); + + process.stdout.write( + `Usage: node benchmark [options]\n\nOptions:\n${help}\n`, + ); + process.exit(0); +} -let maxChildren = Number(ARGV.c) || 1; +let maxChildren = Number(ARGV.children) || 1; const METRICS_DELAY = 100; -const CPUS = os.cpus(); +const CPUS = cpus(); const numCpus = CPUS.length; const CPU_NAMES = ['Redis Process, CPU Used, %']; -const STEPS = Number(ARGV.m) || 10000; -const MSG_DELAY = Number(ARGV.d) || 0; -const USE_GZIP: boolean = !!ARGV.z; -const MSG_MULTIPLIER = Number(ARGV.t) || 0; -const SAFE_DELIVERY: boolean = !!ARGV.s; -const REDIS_PORT: number = Number(ARGV.p) || 6379; +const STEPS = Number(ARGV.messages) || 10000; +const MSG_DELAY = Number(ARGV.delay) || 0; +const USE_GZIP: boolean = !!ARGV.gzip; +const MSG_MULTIPLIER = Number(ARGV['message-multiply-times']) || 0; +const SAFE_DELIVERY: boolean = !!ARGV.safe; +const REDIS_PORT: number = Number(ARGV.port) || 6379; let SAMPLE_MESSAGE: AnyJson; -if (ARGV.e) { +if (ARGV['example-message']) { try { - SAMPLE_MESSAGE = JSON.parse(fs.readFileSync(ARGV.e + '').toString()); + SAMPLE_MESSAGE = JSON.parse( + readFileSync(ARGV['example-message'] + '').toString(), + ); if (MSG_MULTIPLIER) { - SAMPLE_MESSAGE = new Array(MSG_MULTIPLIER) - .fill(SAMPLE_MESSAGE); + SAMPLE_MESSAGE = Array.from( + { length: MSG_MULTIPLIER }, + () => SAMPLE_MESSAGE, + ); } - } catch (err) { - console.warn('Given example message is invalid, ' + - 'proceeding test execution with with standard ' + - 'example message.'); + } catch { + console.warn( + 'Given example message is invalid, ' + + 'proceeding test execution with with standard ' + + 'example message.', + ); } } @@ -120,9 +177,9 @@ for (let i = 0; i < maxChildren; i++) { * @param {number} i * @returns {{idle: number, total: number}} */ -function cpuAvg(i: number) { - const cpus = os.cpus(); - const cpu: any = cpus[i]; +function cpuAvg(i: number): { idle: number; total: number } { + const cpuList = cpus(); + const cpu: any = cpuList[i]; let totalIdle = 0; let totalTick = 0; @@ -134,7 +191,7 @@ function cpuAvg(i: number) { return { idle: totalIdle / cpus.length, - total: totalTick / cpus.length + total: totalTick / cpus.length, }; } @@ -143,16 +200,18 @@ interface MachineStats { memStats: any[]; } +interface BuildStatsInput { + metrics: any[]; + memusage: any[]; +} + /** - * Does stats aggregation and returns results + * Does stat aggregation and returns results * - * @param {any} metrics - * @param {any} memusage + * @param {BuildStatsInput} input * @return {MachineStats} */ -function buildStats( - { metrics, memusage }: any -): MachineStats { +function buildStats({ metrics, memusage }: BuildStatsInput): MachineStats { const stats: any[] = []; const memStats: any[] = ['System Memory Used, %']; @@ -165,67 +224,92 @@ function buildStats( stats[cpu] = [CPU_NAMES[cpu]]; } - stats[cpu].push(100 - ~~(100 * idle / total)); + stats[cpu].push(100 - ~~((100 * idle) / total)); } } for (let i = 0, s = memusage.length; i < s; i++) { - memStats.push(100 - ~~(100 * memusage[i].free / memusage[i].total)); + memStats.push(100 - ~~((100 * memusage[i].free) / memusage[i].total)); } return { stats, memStats }; } +interface ChartConfig { + bindto: string; + data: { columns: any[] }; + point: { show: boolean }; + axis: { + x: { + type: string; + categories: any; + tick: { + centered: boolean; + fit: boolean; + culling: { max: number }; + outer: boolean; + }; + }; + y: { max: number; tick: { outer: boolean } }; + }; + zoom: { enabled: boolean }; +} + /** * Returns chart config for given chart id and stats * * @param {string} id * @param {any[]} stats - * @return {any} + * @return {ChartConfig} */ -function buildChartConfig(id: string, stats: any[]) { +function buildChartConfig(id: string, stats: any[]): ChartConfig { return { bindto: `#${id}`, data: { - columns: stats + columns: stats, }, point: { show: false }, axis: { x: { type: 'category', - categories: stats[0].slice(1).map((v: any, i: number) => - ((i * 100) / 1000).toFixed(1) + 's' as any - ), + categories: stats[0] + .slice(1) + .map( + (_: any, i: number) => + ((i * 100) / 1000).toFixed(1) + 's', + ), tick: { centered: true, fit: false, culling: { max: 20 }, - outer: false - } + outer: false, + }, }, y: { max: 100, - tick: { outer: false } - } + tick: { outer: false }, + }, }, zoom: { - enabled: true - } + enabled: true, + }, }; } /** - * Return bytes count for a given data and bytes key + * Return byte count for a given data and bytes key * * @param {any[]} data * @param {Intl.NumberFormat} fmt * @param {string} key * @return {string} */ -function bytesCount(data: any[], fmt: Intl.NumberFormat, key: string) { - return fmt.format(Math.round(data.reduce((prev, next) => - prev + next[key], 0 - ) / data.length)) +function bytesCount(data: any[], fmt: Intl.NumberFormat, key: string): string { + return fmt.format( + Math.round( + data.reduce((prev, next) => prev + next[key], 0) / data.length, + ), + ); } /** @@ -234,17 +318,15 @@ function bytesCount(data: any[], fmt: Intl.NumberFormat, key: string) { * @param {{ metrics: any, memusage: any }} stats * @param {any} data */ -function saveStats({ metrics, memusage }: any, data: any[]) { +function saveStats({ metrics, memusage }: any, data: any[]) { const { stats, memStats } = buildStats({ metrics, memusage }); const config = buildChartConfig('cpu-usage', stats); const memConfig = buildChartConfig('memory-usage', [memStats]); - const fmt = new Intl.NumberFormat( - 'en-US', { maximumSignificantDigits: 3 } - ); + const fmt = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 }); // language=HTML let html = ` - + IMQ Benchmark results @@ -254,57 +336,68 @@ function saveStats({ metrics, memusage }: any, data: any[]) {

IMQ Benchmark Results

-

This test was executed using CPU affinity assignment for each running - process. Redis server has it's own dedicated CPU core, all worker processes +

This test was executed using a CPU affinity assignment for each running + process. Redis server has its own dedicated CPU core, all worker processes are attached to their own cores as well. Each worker process running a bunch - of requests simultaneously in asynchronous manner, so depending on the - given test parameters all requests are executed almost at the same time. + of requests simultaneously in an asynchronous manner, so depending on the + given test parameters, all requests are executed almost at the same time.

- NOTE: In MacOS it is not possible to implement CPU affinity assignment for + NOTE: In macOS it is not possible to implement CPU affinity assignment for a given process, so there is no way to guaranty a proper process load visibility.

Test Execution Information

    -
  • Execution Datetime: ${ new Date().toISOString() }
  • +
  • Execution Datetime: ${new Date().toISOString()}
  • System Info:
      -
    • CPU: ${CPUS[0].model} × ${ numCpus } cores
    • -
    • CPU Clock Speed: ${ CPUS[0].speed }Mhz
    • -
    • RAM: ${ Math.ceil(os.totalmem() / Math.pow(1024, 3)) }GB
    • -
    • OS Architecture: ${ os.arch() }
    • -
    • OS Platform: ${ os.platform() }
    • -
    • Node Version: ${ process.versions.node }
    • +
    • CPU: ${CPUS[0].model} × ${numCpus} cores
    • +
    • CPU Clock Speed: ${CPUS[0].speed}Mhz
    • +
    • RAM: ${Math.ceil(totalmem() / Math.pow(1024, 3))}GB
    • +
    • OS Architecture: ${arch()}
    • +
    • OS Platform: ${platform()}
    • +
    • Node Version: ${process.versions.node}
  • Number of workers: ${fmt.format(maxChildren)}
  • Number of messages per worker: ${fmt.format(STEPS)}
  • Total messages executed: ${fmt.format(STEPS * maxChildren)}
  • -
  • Round-trip ratio across all workers is: ${ - fmt.format(data.reduce((prev, next) => - prev + next.ratio, 0 - )) - } msg/sec
  • -
  • Average message payload to redis is: ${ - bytesCount(data, fmt, 'bytesLen') - } bytes
  • -
  • Average source message payload is: ${ - bytesCount(data, fmt, 'srcBytesLen') - } bytes
  • -
  • Average time of all messages delivery is: ${ - fmt.format(Number((data.reduce((prev, next) => - prev + next.time, 0 - ) / 1000 / data.length).toFixed(2))) - } sec ±10 ms
  • -
  • Max delivery time is: ${ - fmt.format( - Number((Math.max.apply(null, data.map((item => item.time))) - / 1000).toFixed(2))) - } sec ±10 ms
  • +
  • Round-trip ratio across all workers is: ${fmt.format( + data.reduce((prev, next) => prev + next.ratio, 0), + )} msg/sec
  • +
  • Average message payload to redis is: ${bytesCount( + data, + fmt, + 'bytesLen', + )} bytes
  • +
  • Average source message payload is: ${bytesCount( + data, + fmt, + 'srcBytesLen', + )} bytes
  • +
  • The average time of all messages deliveries is: ${fmt.format( + Number( + ( + data.reduce((prev, next) => prev + next.time, 0) / + 1000 / + data.length + ).toFixed(2), + ), + )} sec ±10 ms
  • +
  • Max delivery time is: ${fmt.format( + Number( + ( + Math.max.apply( + null, + data.map(item => item.time), + ) / 1000 + ).toFixed(2), + ), + )} sec ±10 ms
  • ${MSG_DELAY ? '
  • Message delivery delay used: ' + MSG_DELAY + '
  • ' : ''} -
  • Gzip compression for messages is: ${ USE_GZIP ? 'On' : 'Off' }
  • -
  • Safe delivery is: ${ SAFE_DELIVERY ? 'On' : 'Off' }
  • +
  • Gzip compression for messages is: ${USE_GZIP ? 'On' : 'Off'}
  • +
  • Safe delivery is: ${SAFE_DELIVERY ? 'On' : 'Off'}

CPU Usage

@@ -323,24 +416,20 @@ function saveStats({ metrics, memusage }: any, data: any[]) { `; const htmlFile = resolve(__dirname, `../benchmark-result/${uuid()}.html`); - if (!fs.existsSync('./benchmark-result')) { - fs.mkdirSync('./benchmark-result'); + if (!existsSync('./benchmark-result')) { + mkdirSync('./benchmark-result'); } - fs.writeFileSync(htmlFile, html, { encoding: 'utf8' }); - - console.log('Benchmark stats saved!'); - console.log(`Opening file://${htmlFile}`); + writeFileSync(htmlFile, html, { encoding: 'utf8' }); - import('open').then(open => - open.default(`file://${htmlFile}`, { wait: false }), - ); + console.info('Benchmark stats saved!'); + console.info(`Open in browser: file://${htmlFile}`); process.exit(0); } // main program: -if (cluster.isMaster) { +if (cluster.isPrimary) { setAffinity(1); const statsWorker = cluster.fork(); @@ -350,7 +439,7 @@ if (cluster.isMaster) { const data: any[] = []; statsWorker.on('message', (msg: any) => { - if (/^metrics:/.test(msg)) { + if (msg.startsWith('metrics:')) { saveStats(JSON.parse(msg.split('metrics:').pop() || ''), data); process.exit(0); } @@ -361,7 +450,7 @@ if (cluster.isMaster) { worker.send(`imq ${i}`); worker.on('message', (msg: string) => { - if (/^data:/.test(msg)) { + if (msg.startsWith('data:')) { done++; data.push(JSON.parse(msg.split('data:').pop() || '')); @@ -371,15 +460,13 @@ if (cluster.isMaster) { } }); } -} - -else { +} else { const metrics: any[] = []; const memusage: any[] = []; let metricsInterval: any; process.on('message', async (msg: string) => { - if (/^imq/.test(msg)) { + if (msg.startsWith('imq')) { const index = parseInt(String(msg.split(/\s+/).pop()), 10); const core = numCpus <= 2 ? 1 : index + 2; @@ -392,55 +479,56 @@ else { MSG_DELAY, USE_GZIP, SAFE_DELIVERY, - SAMPLE_MESSAGE + SAMPLE_MESSAGE, ); (process).send('data:' + JSON.stringify(data)); - } - - catch (err) { + } catch (err) { (process).send('data:' + JSON.stringify(null)); - console.error(err.stack); + console.error(err instanceof Error ? err.stack : err); process.exit(1); } - } - - else if (msg === 'stats') { + } else if (msg === 'stats') { setAffinity(1); const redisProcess = exec('ps ax|grep redis-server') - .toString('utf8') - .split(/\r?\n/)[0]; + .toString('utf8') + .split(/\r?\n/)[0]; const core = numCpus > 1 ? 1 : 0; - if (core && os.platform() === 'linux' && + if ( + core && + platform() === 'linux' && /redis-server/.test(redisProcess) && !/grep/.test(redisProcess) ) { const redisPid = parseInt(redisProcess.split(/\s+/)[0], 10); - redisPid && exec(`taskset -cp ${core} ${redisPid}`); + if (redisPid) { + exec(`taskset -cp ${core} ${redisPid}`); + } } metricsInterval = setInterval(() => { - metrics.push( - CPU_NAMES.map((name: string, i: number) => cpuAvg(i + 1)) - ); + metrics.push(CPU_NAMES.map((_, i: number) => cpuAvg(i + 1))); memusage.push({ - total: os.totalmem(), - free: os.freemem() + total: totalmem(), + free: freemem(), }); }, METRICS_DELAY); - } - - else if (msg === 'stop') { - metricsInterval && clearInterval(metricsInterval); + } else if (msg === 'stop') { + if (metricsInterval) { + clearInterval(metricsInterval); + } metricsInterval = null; - console.log('Finalizing...'); - (process).send('metrics:' + JSON.stringify({ - metrics, - memusage - })); + console.info('Finalizing...'); + (process).send( + 'metrics:' + + JSON.stringify({ + metrics, + memusage, + }), + ); process.exit(0); } }); -} \ No newline at end of file +} diff --git a/benchmark/redis-test.ts b/benchmark/redis-test.ts index 82e7c17..77991cb 100644 --- a/benchmark/redis-test.ts +++ b/benchmark/redis-test.ts @@ -21,66 +21,66 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import IMQ, { - IMQOptions, - IJson, - uuid, - pack, - JsonObject, AnyJson, -} from '../index'; +import { randomUUID as uuid } from 'node:crypto'; +import IMQ, { IMQOptions, JsonObject, AnyJson } from '../index'; +import { pack } from '../src/helpers'; /** * Sample message used within tests */ const JSON_EXAMPLE: JsonObject = { - "Glossary": { - "Title": "Example Glossary", - "GlossDiv": { - "Title": "ß∆", - "GlossList": [{ - "GlossEntry": { - "ID": "SGML", - "SortAs": "SGML", - "GlossTerm": "Standard Generalized Markup Language", - "Acronym": "SGML", - "Abbrev": "ISO 8879:1986", - "GlossDef": { - "para": "A meta-markup language, used to create markup languages such as DocBook.", - "GlossSeeAlso": ["GML", "XML"] + Glossary: { + Title: 'Example Glossary', + GlossDiv: { + Title: 'ß∆', + GlossList: [ + { + GlossEntry: { + ID: 'SGML', + SortAs: 'SGML', + GlossTerm: 'Standard Generalized Markup Language', + Acronym: 'SGML', + Abbrev: 'ISO 8879:1986', + GlossDef: { + para: 'A meta-markup language, used to create markup languages such as DocBook.', + GlossSeeAlso: ['GML', 'XML'], + }, + GlossSee: 'markup, non-markup, joke-cup', }, - "GlossSee": "markup, non-markup, joke-cup" - } - }, { - "GlossEntry": { - "ID": "SGML", - "SortAs": "SGML", - "GlossTerm": "Standard Generalized Markup Language", - "Acronym": "SGML", - "Abbrev": "ISO 8879:1986", - "MoreBytes": "", - "GlossDef": { - "para": "A meta-markup language, used to create markup languages such as DocBook.", - "GlossSeeAlso": ["GML", "XML", "FML", "RML", "FCL"] + }, + { + GlossEntry: { + ID: 'SGML', + SortAs: 'SGML', + GlossTerm: 'Standard Generalized Markup Language', + Acronym: 'SGML', + Abbrev: 'ISO 8879:1986', + MoreBytes: '', + GlossDef: { + para: 'A meta-markup language, used to create markup languages such as DocBook.', + GlossSeeAlso: ['GML', 'XML', 'FML', 'RML', 'FCL'], + }, + GlossSee: 'markup', }, - "GlossSee": "markup" - } - }, { - "GlossEntry": { - "ID": "SGML", - "SortAs": "SGML", - "GlossTerm": "Standard Generalized Markup Language", - "Acronym": "SGML", - "Abbrev": "ISO 8879:1986", - "MoreBytes": [0, 1, 2, 3, 4, 5, 6], - "GlossDef": { - "para": "A meta-markup language, used to create markup languages such as DocBook.", - "GlossSeeAlso": ["GML", "XML", "OGL", "PPL"] + }, + { + GlossEntry: { + ID: 'SGML', + SortAs: 'SGML', + GlossTerm: 'Standard Generalized Markup Language', + Acronym: 'SGML', + Abbrev: 'ISO 8879:1986', + MoreBytes: [0, 1, 2, 3, 4, 5, 6], + GlossDef: { + para: 'A meta-markup language, used to create markup languages such as DocBook.', + GlossSeeAlso: ['GML', 'XML', 'OGL', 'PPL'], + }, + GlossSee: 'markup', }, - "GlossSee": "markup" - } - }] - } - } + }, + ], + }, + }, }; /** @@ -90,7 +90,7 @@ const JSON_EXAMPLE: JsonObject = { * @param {boolean} useGzip * @returns {number} */ -export function bytes(str: string, useGzip: boolean = false) { +export function bytes(str: string, useGzip: boolean = false): number { return Buffer.from(str, useGzip ? 'binary' : 'utf8').length; } @@ -102,7 +102,7 @@ export function bytes(str: string, useGzip: boolean = false) { * @param {number} [msgDelay] * @param {boolean} [useGzip] * @param {boolean} safeDelivery - * @param {IJson} jsonExample + * @param {AnyJson} jsonExample * @returns {Promise} */ export async function run( @@ -111,74 +111,76 @@ export async function run( msgDelay: number = 0, useGzip: boolean = false, safeDelivery: boolean = false, - jsonExample: AnyJson = JSON_EXAMPLE -) { + jsonExample: AnyJson = JSON_EXAMPLE, +): Promise { const bytesLen = bytes( (useGzip ? pack : JSON.stringify)(jsonExample), - useGzip + useGzip, ); const srcBytesLen = bytes(JSON.stringify(jsonExample)); - return new Promise(async (resolve) => { - const queueName = `imq-test:${uuid()}`; - const options: Partial = { - vendor: 'Redis', - port, - useGzip, - safeDelivery - }; - const mq = await IMQ.create(queueName, options).start(); + return new Promise(resolve => { + void (async () => { + const queueName = `imq-test:${uuid()}`; + const options: Partial = { + vendor: 'Redis', + port, + useGzip, + safeDelivery, + }; + const mq = await IMQ.create(queueName, options).start(); - let count = 0; - const fmt = new Intl.NumberFormat( - 'en-US', { maximumSignificantDigits: 3 } - ); + let count = 0; + const fmt = new Intl.NumberFormat('en-US', { + maximumSignificantDigits: 3, + }); - mq.on('message', () => count++); + mq.on('message', () => count++); - if (msgDelay) { - console.log( - 'Sending %s messages, using %s delay please, wait...', - fmt.format(steps), - fmt.format(msgDelay) - ); - } else { - console.log( - 'Sending %s messages, please, wait...', - fmt.format(steps) - ); - } + if (msgDelay) { + console.info( + 'Sending %s messages, using %s delay please, wait...', + fmt.format(steps), + fmt.format(msgDelay), + ); + } else { + console.info( + 'Sending %s messages, please, wait...', + fmt.format(steps), + ); + } - const start = Date.now(); + const start = Date.now(); - for (let i = 0; i < steps; i++) { - mq.send(queueName, jsonExample as JsonObject, msgDelay).catch(); - } + for (let i = 0; i < steps; i++) { + mq.send(queueName, jsonExample as JsonObject, msgDelay).catch(); + } - const interval = setInterval(async () => { - if (count >= steps) { - const time = Date.now() - start; - const ratio = count / (time / 1000); + const interval = setInterval(async () => { + if (count >= steps) { + const time = Date.now() - start; + const ratio = count / (time / 1000); - console.log( - '%s is sent/received in %s ±10 ms', - fmt.format(count), - fmt.format(time) - ); - console.log( - 'Round-trip ratio: %s messages/sec', - fmt.format(ratio) - ); - console.log( - 'Message payload is: %s bytes', - fmt.format(bytesLen) - ); + console.info( + '%s is sent/received in %s ±10 ms', + fmt.format(count), + fmt.format(time), + ); + console.info( + 'Round-trip ratio: %s messages/sec', + fmt.format(ratio), + ); + console.info( + 'Message payload is: %s bytes', + fmt.format(bytesLen), + ); - mq.destroy().catch(); + mq.destroy().catch(); - clearInterval(interval); - resolve({ count, time, ratio, bytesLen, srcBytesLen }); - } - }, 10); + clearInterval(interval); + resolve({ count, time, ratio, bytesLen, srcBytesLen }); + } + }, 10); + })(); }); -} \ No newline at end of file +} diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index a0210a5..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,98 +0,0 @@ -/*! - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ -import typescriptEslintEslintPlugin from "@typescript-eslint/eslint-plugin"; -import tsParser from "@typescript-eslint/parser"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import js from "@eslint/js"; -import { FlatCompat } from "@eslint/eslintrc"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all -}); - -export default [...compat.extends( - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking", -), { - plugins: { - "@typescript-eslint": typescriptEslintEslintPlugin, - }, - - languageOptions: { - parser: tsParser, - ecmaVersion: 5, - sourceType: "module", - - parserOptions: { - project: "tsconfig.json", - }, - }, - - rules: { - "@typescript-eslint/interface-name-prefix": "off", - "@typescript-eslint/explicit-function-return-type": ["warn"], - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unsafe-assignment": "warn", - "@typescript-eslint/no-unsafe-argument": "warn", - "no-trailing-spaces": "warn", - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["warn"], - "semi": ["warn", "always"], - - "no-console": ["warn", { - allow: ["warn", "error", "info"], - }], - - "no-debugger": "error", - "arrow-parens": ["warn", "as-needed"], - - "quotes": ["error", "single", { - avoidEscape: true, - allowTemplateLiterals: true, - }], - - "comma-dangle": ["warn", { - arrays: "always-multiline", - objects: "always-multiline", - imports: "always-multiline", - exports: "always-multiline", - functions: "always-multiline", - }], - - "max-len": ["warn", { - code: 80, - ignoreComments: true, - ignoreRegExpLiterals: true, - ignoreTrailingComments: true, - ignoreTemplateLiterals: true, - ignoreStrings: true, - ignoreUrls: true, - }], - }, -}]; diff --git a/index.ts b/index.ts index a2f8af0..755ab2c 100644 --- a/index.ts +++ b/index.ts @@ -21,7 +21,6 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import 'reflect-metadata'; import { IMessageQueue, IMessageQueueConstructor, @@ -65,8 +64,9 @@ export default class IMQ { ClassName = `Clustered${ClassName}`; } - const Adapter: IMessageQueueConstructor = - require(`${__dirname}/src/${ClassName}.js`)[ClassName]; + const Adapter: IMessageQueueConstructor = require( + `${__dirname}/src/${ClassName}.js`, + )[ClassName]; return new Adapter(name, options, mode); } diff --git a/package-lock.json b/package-lock.json index c5a9f71..f95cd48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,884 +1,775 @@ { "name": "@imqueue/core", - "version": "2.0.26", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@imqueue/core", - "version": "2.0.26", + "version": "3.0.0", "license": "GPL-3.0-only", "dependencies": { - "ioredis": "^5.7.0" + "ioredis": "^5.11.1" }, "devDependencies": { - "@eslint/js": "^9.33.0", - "@types/chai": "^5.2.2", - "@types/eslint__eslintrc": "^2.1.2", - "@types/mocha": "^10.0.0", "@types/mock-require": "^3.0.0", - "@types/node": "^24.2.1", - "@types/sinon": "^17.0.4", - "@types/yargs": "^17.0.33", - "@typescript-eslint/eslint-plugin": "^8.39.0", - "@typescript-eslint/parser": "^8.39.0", - "@typescript-eslint/typescript-estree": "^8.39.0", - "chai": "^5.2.1", - "coveralls-next": "^5.0.0", - "eslint": "^9.33.0", - "eslint-plugin-jsdoc": "^52.0.4", - "mocha": "^11.7.1", - "mocha-lcov-reporter": "^1.3.0", + "@types/node": "^24.9.1", "mock-require": "^3.0.3", - "nyc": "^17.1.0", - "open": "^10.2.0", - "reflect-metadata": "^0.2.2", - "sinon": "^21.0.0", - "source-map-support": "^0.5.21", - "ts-node": "^10.9.2", - "typedoc": "^0.28.9", - "typescript": "^5.9.2", - "yargs": "^18.0.0" + "oxfmt": "0.57.0", + "oxlint": "1.72.0", + "typedoc": "^0.28.20", + "typescript": "^6.0.3" } }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" } }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.57.0.tgz", + "integrity": "sha512-qVBsEO+KugOsCmUHcO8iqNnqc65p7PCKpCs8M66mPZ+Ri+CWbcpoQOEJBg2OTu03+0qu++NK1jj6IzvQVs0Sig==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.57.0.tgz", + "integrity": "sha512-mp6PibWbao3aizijcheOeHQaYEhcUAt8pwLniYbtLfHxL/psFF0BykAwCj+s3c6qIpa8yN8keZICWrqtZ70w8g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.57.0.tgz", + "integrity": "sha512-T+0stuCBqmUVY+aMIvrgXhzGhHO3sD5tNiiEcYqgSdPsnukskQqn2u5qOVD0sv1l7RLdFS5Z/f5Wi9Ktyjr3Eg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.57.0.tgz", + "integrity": "sha512-O+3JbqWs/mCI2oi4xfhRO2IVPFJNDDEBV8Odo+ZpmsUOeKJfjXoNH7nDmBEQcDgK7NfjDIyE7kRgYSZcTLDO0A==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.57.0.tgz", + "integrity": "sha512-pxwhxVC+JkLX9twOQ/8C/vbuOQcMZyKIDmiRDZfO7yITuVcIdZCiLRqqf4QOxb2+8FWrRXzQpm+1DBKcMpHSSQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.57.0.tgz", + "integrity": "sha512-pxBU4zH2imB/MDBfth2rOMeVxXUMjRQLCazagwLARIFH3hVlxZJBlM4nSnHXaIHJK4/qezoFCIORN6AY8Mra4A==", + "cpu": [ + "arm" + ], "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.57.0.tgz", + "integrity": "sha512-JAprOzt8tycYou36ZgEw14DlRHTiN8qdtKANdV3VZIRIvTI/lh/cX13c9pJ/EnDk2GT3FASH7KvCgQ2AufAifQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.57.0.tgz", + "integrity": "sha512-ajtjaxSaj9xl4BW7REt+Cef/ttzbAq00Bq4z7JUDZEfgFXdwSjH8K9bF+IcIJzZB9lKqMfQ4eHuSFOvvlvtqOg==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.57.0.tgz", + "integrity": "sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.57.0.tgz", + "integrity": "sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ==", + "cpu": [ + "ppc64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.57.0.tgz", + "integrity": "sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA==", + "cpu": [ + "riscv64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.57.0.tgz", + "integrity": "sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ==", + "cpu": [ + "riscv64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.57.0.tgz", + "integrity": "sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA==", + "cpu": [ + "s390x" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.57.0.tgz", + "integrity": "sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.57.0.tgz", + "integrity": "sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.57.0.tgz", + "integrity": "sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.57.0.tgz", + "integrity": "sha512-MYLAsDnhdNsSGheLYhWgbk0vfIrlS84iQYun/y21fX6u0jj8iBtYtbpZMdiqYeuf8U12eVPUjVY2xE2NrCfJ0g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.57.0.tgz", + "integrity": "sha512-PBwdzZALJY/jcCx2E6is0yu+cuVXeySTDmwuseD+9j0mHqlRNxwlKgsyRTBed/woPeqfVfuXfWjoq4Cx2Zt3Eg==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.57.0.tgz", + "integrity": "sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@es-joy/jsdoccomment": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.52.0.tgz", - "integrity": "sha512-BXuN7BII+8AyNtn57euU2Yxo9yA/KUDNzrpXyi3pfqKmBhhysR6ZWOebFh3vyPoqA3/j1SOvGgucElMGwlXing==", + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", + "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.8", - "@typescript-eslint/types": "^8.34.1", - "comment-parser": "1.4.1", - "esquery": "^1.6.0", - "jsdoc-type-pratt-parser": "~4.1.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=20.11.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", + "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", + "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", + "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", + "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz", + "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==", + "cpu": [ + "arm" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "*" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", - "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz", + "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.16.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz", + "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz", + "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz", + "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==", + "cpu": [ + "ppc64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz", + "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==", + "cpu": [ + "riscv64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 4" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz", + "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "*" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/js": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz", + "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==", + "cpu": [ + "s390x" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz", + "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz", + "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.16.0", - "levn": "^0.4.1" - }, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@gerrit0/mini-shiki": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.14.0.tgz", - "integrity": "sha512-c5X8fwPLOtUS8TVdqhynz9iV0GlOtFUT1ppXYzUUlEXe4kbZ/mvMT8wXoT8kCwUka+zsiloq7sD3pZ3+QVTuNQ==", + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz", + "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@shikijs/engine-oniguruma": "^3.14.0", - "@shikijs/langs": "^3.14.0", - "@shikijs/themes": "^3.14.0", - "@shikijs/types": "^3.14.0", - "@shikijs/vscode-textmate": "^10.0.2" + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz", + "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18.18.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz", + "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18.18.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz", + "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" } }, - "node_modules/@ioredis/commands": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", - "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.14.0.tgz", - "integrity": "sha512-TNcYTYMbJyy+ZjzWtt0bG5y4YyMIWC2nyePz+CFMWqm+HnZZyy9SWMgo8Z6KBJVIZnx8XUXS8U2afO6Y0g1Oug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.14.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@shikijs/langs": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.14.0.tgz", - "integrity": "sha512-DIB2EQY7yPX1/ZH7lMcwrK5pl+ZkP/xoSpUzg9YC8R+evRCCiSQ7yyrvEyBsMnfZq4eBzLzBlugMyTAf13+pzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.14.0" + "@shikijs/types": "3.23.0" } }, "node_modules/@shikijs/themes": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.14.0.tgz", - "integrity": "sha512-fAo/OnfWckNmv4uBoUu6dSlkcBc+SA1xzj5oUSaz5z3KqHtEbUypg/9xxgJARtM6+7RVm0Q6Xnty41xA1ma1IA==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.14.0" + "@shikijs/types": "3.23.0" } }, "node_modules/@shikijs/types": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.14.0.tgz", - "integrity": "sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", "dev": true, "license": "MIT", "dependencies": { @@ -893,121 +784,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/@sinonjs/samsam": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", - "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "type-detect": "^4.1.0" - } - }, - "node_modules/@sinonjs/samsam/node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint__eslintrc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@types/eslint__eslintrc/-/eslint__eslintrc-2.1.2.tgz", - "integrity": "sha512-qXvzPFY7Rz05xD8ZApXJ3S8xStQD2Ibzu3EFIF0UMNOAfLY5xUu3H61q0JrHo2OXD6rcFG75yUxNQbkKtFKBSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -1018,20 +794,6 @@ "@types/unist": "*" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mocha": { - "version": "10.0.10", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", - "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mock-require": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/mock-require/-/mock-require-3.0.0.tgz", @@ -1040,32 +802,15 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", - "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/sinon": { - "version": "17.0.4", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", - "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", "dependencies": { - "@types/sinonjs__fake-timers": "*" + "undici-types": "~7.18.0" } }, - "node_modules/@types/sinonjs__fake-timers": { - "version": "15.0.1", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", - "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1073,4393 +818,445 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/yargs": { - "version": "17.0.34", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", - "integrity": "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", - "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/type-utils": "8.46.2", - "@typescript-eslint/utils": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.46.2", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", - "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", - "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.2", - "@typescript-eslint/types": "^8.46.2", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", - "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", - "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", - "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/utils": "8.46.2", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", - "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", - "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.46.2", - "@typescript-eslint/tsconfig-utils": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", - "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", - "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/append-transform": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", - "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-require-extensions": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/archy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/are-docs-informative": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", - "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", - "integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true, - "license": "ISC" - }, - "node_modules/browserslist": { - "version": "4.27.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", - "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.8.19", - "caniuse-lite": "^1.0.30001751", - "electron-to-chromium": "^1.5.238", - "node-releases": "^2.0.26", - "update-browserslist-db": "^1.1.4" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caching-transform": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", - "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasha": "^5.0.0", - "make-dir": "^3.0.0", - "package-hash": "^4.0.0", - "write-file-atomic": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/comment-parser": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", - "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/coveralls-next": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/coveralls-next/-/coveralls-next-5.0.0.tgz", - "integrity": "sha512-RCj6Oflf6iQtN3Q5b0SSemEbQBzeBjQlLUrc3bfNECTy83hMJA9krdNZ5GTRm7Jpbyo92yKUbQDP5FYlWcL5sA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "js-yaml": "4.1.0", - "lcov-parse": "1.0.0", - "log-driver": "1.2.7", - "minimist": "1.2.8" - }, - "bin": { - "coveralls": "bin/coveralls.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/deep-eql": { - "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, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-require-extensions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", - "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "strip-bom": "^4.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.241", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.241.tgz", - "integrity": "sha512-ILMvKX/ZV5WIJzzdtuHg8xquk2y0BOGlFOxBVwTpbiXqWIH0hamG45ddU4R3PQ0gYu+xgo0vdHXHli9sHIGb4w==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", - "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.1", - "@eslint/core": "^0.16.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.38.0", - "@eslint/plugin-kit": "^0.4.0", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-jsdoc": { - "version": "52.0.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-52.0.4.tgz", - "integrity": "sha512-be5OzGlLExvcK13Il3noU7/v7WmAQGenTmCaBKf1pwVtPOb6X+PGFVnJad0QhMj4KKf45XjE4hbsBxv25q1fTg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@es-joy/jsdoccomment": "~0.52.0", - "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.1", - "debug": "^4.4.1", - "escape-string-regexp": "^4.0.0", - "espree": "^10.4.0", - "esquery": "^1.6.0", - "parse-imports-exports": "^0.2.4", - "semver": "^7.7.2", - "spdx-expression-parse": "^4.0.0" - }, - "engines": { - "node": ">=20.11.0" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "dev": true, - "license": "MIT", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fromentries": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", - "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", - "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", - "dev": true, - "license": "ISC" - }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hasha": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", - "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-stream": "^2.0.0", - "type-fest": "^0.8.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ioredis": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", - "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "1.4.0", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-hook": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", - "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "append-transform": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-processinfo": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", - "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", - "dev": true, - "license": "ISC", - "dependencies": { - "archy": "^1.0.0", - "cross-spawn": "^7.0.3", - "istanbul-lib-coverage": "^3.2.0", - "p-map": "^3.0.0", - "rimraf": "^3.0.0", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report/node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsdoc-type-pratt-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", - "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/lcov-parse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz", - "integrity": "sha512-aprLII/vPzuQvYZnDRU78Fns9I2Ag3gi4Ipga/hxnVMCZC8DnR2nI7XBqrPoywGfxqIx/DgarGvDJZAD3YBTgQ==", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "lcov-parse": "bin/cli.js" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, - "node_modules/lodash.flattendeep": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", - "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-driver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz", - "integrity": "sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=0.8.6" - } - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lunr": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", - "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mocha": { - "version": "11.7.4", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.4.tgz", - "integrity": "sha512-1jYAaY8x0kAZ0XszLWu14pzsf4KV740Gld4HXkhNTXwcHx4AUEDkPzgEHg9CM5dVcW+zv036tjpsEbLraPJj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "browser-stdout": "^1.3.1", - "chokidar": "^4.0.1", - "debug": "^4.3.5", - "diff": "^7.0.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^10.4.5", - "he": "^1.2.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^9.0.5", - "ms": "^2.1.3", - "picocolors": "^1.1.1", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^9.2.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1", - "yargs-unparser": "^2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/mocha-lcov-reporter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/mocha-lcov-reporter/-/mocha-lcov-reporter-1.3.0.tgz", - "integrity": "sha512-/5zI2tW4lq/ft8MGpYQ1nIH6yePPtIzdGeUEwFMKfMRdLfAQ1QW2c68eEJop32tNdN5srHa/E2TzB+erm3YMYA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/mocha/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/mocha/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mocha/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/mocha/node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/mocha/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mocha/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mocha/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/mocha/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/mocha/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mock-require": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/mock-require/-/mock-require-3.0.3.tgz", - "integrity": "sha512-lLzfLHcyc10MKQnNUCv7dMcoY/2Qxd6wJfbqCcVk3LDb8An4hF6ohk5AztrvgKhJCqj36uyzi/p5se+tvyD+Wg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-caller-file": "^1.0.2", - "normalize-path": "^2.1.1" - }, - "engines": { - "node": ">=4.3.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-preload": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", - "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "process-on-spawn": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/node-releases": { - "version": "2.0.26", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", - "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "remove-trailing-separator": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nyc": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.1.0.tgz", - "integrity": "sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "caching-transform": "^4.0.0", - "convert-source-map": "^1.7.0", - "decamelize": "^1.2.0", - "find-cache-dir": "^3.2.0", - "find-up": "^4.1.0", - "foreground-child": "^3.3.0", - "get-package-type": "^0.1.0", - "glob": "^7.1.6", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-hook": "^3.0.0", - "istanbul-lib-instrument": "^6.0.2", - "istanbul-lib-processinfo": "^2.0.2", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.0.2", - "make-dir": "^3.0.0", - "node-preload": "^0.2.1", - "p-map": "^3.0.0", - "process-on-spawn": "^1.0.0", - "resolve-from": "^5.0.0", - "rimraf": "^3.0.0", - "signal-exit": "^3.0.2", - "spawn-wrap": "^2.0.0", - "test-exclude": "^6.0.0", - "yargs": "^15.0.2" - }, - "bin": { - "nyc": "bin/nyc.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/nyc/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/nyc/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/nyc/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/nyc/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/nyc/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/nyc/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/nyc/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/nyc/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/nyc/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/nyc/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/package-hash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", - "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "graceful-fs": "^4.1.15", - "hasha": "^5.0.0", - "lodash.flattendeep": "^4.4.0", - "release-zalgo": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-imports-exports": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", - "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-statements": "1.0.11" - } - }, - "node_modules/parse-statements": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", - "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/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, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/process-on-spawn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.1.0.tgz", - "integrity": "sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "fromentries": "^1.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/release-zalgo": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", - "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", - "dev": true, - "license": "ISC", - "dependencies": { - "es6-error": "^4.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/remove-trailing-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", - "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", - "dev": true, - "license": "ISC" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true, - "license": "ISC" - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sinon": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", - "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.5", - "@sinonjs/samsam": "^8.0.1", - "diff": "^7.0.0", - "supports-color": "^7.2.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/sinon" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/spawn-wrap": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", - "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^2.0.0", - "is-windows": "^1.0.2", - "make-dir": "^3.0.0", - "rimraf": "^3.0.0", - "signal-exit": "^3.0.2", - "which": "^2.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/spawn-wrap/node_modules/foreground-child": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", - "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/spawn-wrap/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/ts-node/node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, - "node_modules/typedoc": { - "version": "0.28.14", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.14.tgz", - "integrity": "sha512-ftJYPvpVfQvFzpkoSfHLkJybdA/geDJ8BGQt/ZnkkhnBYoYW6lBgPQXu6vqLxO4X75dA55hX8Af847H5KXlEFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@gerrit0/mini-shiki": "^3.12.0", - "lunr": "^2.3.9", - "markdown-it": "^14.1.0", - "minimatch": "^9.0.5", - "yaml": "^2.8.1" - }, - "bin": { - "typedoc": "bin/typedoc" - }, - "engines": { - "node": ">= 18", - "pnpm": ">= 10" - }, - "peerDependencies": { - "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "ISC" + "license": "Python-2.0" }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": "18 || 20 || >=22" } }, - "node_modules/workerpool": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", - "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": "18 || 20 || >=22" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ms": "^2.1.3" }, "engines": { - "node": ">=10" + "node": ">=6.0" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": ">=0.10" } }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">=8" + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", "dev": true, + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" }, "engines": { - "node": ">=8" + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" } }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "dependencies": { + "uc.micro": "^2.0.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", "dev": true, - "license": "ISC", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "is-wsl": "^3.1.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=18" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "node_modules/mock-require": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mock-require/-/mock-require-3.0.3.tgz", + "integrity": "sha512-lLzfLHcyc10MKQnNUCv7dMcoY/2Qxd6wJfbqCcVk3LDb8An4hF6ohk5AztrvgKhJCqj36uyzi/p5se+tvyD+Wg==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "get-caller-file": "^1.0.2", + "normalize-path": "^2.1.1" + }, "engines": { - "node": ">=10" + "node": ">=4.3.0" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "node_modules/normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" + "license": "MIT", + "dependencies": { + "remove-trailing-separator": "^1.0.1" }, "engines": { - "node": ">= 14.6" + "node": ">=0.10.0" } }, - "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "node_modules/oxfmt": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.57.0.tgz", + "integrity": "sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.57.0", + "@oxfmt/binding-android-arm64": "0.57.0", + "@oxfmt/binding-darwin-arm64": "0.57.0", + "@oxfmt/binding-darwin-x64": "0.57.0", + "@oxfmt/binding-freebsd-x64": "0.57.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.57.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.57.0", + "@oxfmt/binding-linux-arm64-gnu": "0.57.0", + "@oxfmt/binding-linux-arm64-musl": "0.57.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.57.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.57.0", + "@oxfmt/binding-linux-riscv64-musl": "0.57.0", + "@oxfmt/binding-linux-s390x-gnu": "0.57.0", + "@oxfmt/binding-linux-x64-gnu": "0.57.0", + "@oxfmt/binding-linux-x64-musl": "0.57.0", + "@oxfmt/binding-openharmony-arm64": "0.57.0", + "@oxfmt/binding-win32-arm64-msvc": "0.57.0", + "@oxfmt/binding-win32-ia32-msvc": "0.57.0", + "@oxfmt/binding-win32-x64-msvc": "0.57.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } } }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "node_modules/oxlint": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", + "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==", "dev": true, - "license": "ISC", + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.72.0", + "@oxlint/binding-android-arm64": "1.72.0", + "@oxlint/binding-darwin-arm64": "1.72.0", + "@oxlint/binding-darwin-x64": "1.72.0", + "@oxlint/binding-freebsd-x64": "1.72.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", + "@oxlint/binding-linux-arm-musleabihf": "1.72.0", + "@oxlint/binding-linux-arm64-gnu": "1.72.0", + "@oxlint/binding-linux-arm64-musl": "1.72.0", + "@oxlint/binding-linux-ppc64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-musl": "1.72.0", + "@oxlint/binding-linux-s390x-gnu": "1.72.0", + "@oxlint/binding-linux-x64-gnu": "1.72.0", + "@oxlint/binding-linux-x64-musl": "1.72.0", + "@oxlint/binding-openharmony-arm64": "1.72.0", + "@oxlint/binding-win32-arm64-msvc": "1.72.0", + "@oxlint/binding-win32-ia32-msvc": "1.72.0", + "@oxlint/binding-win32-x64-msvc": "1.72.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } } }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "dev": true, "license": "MIT", - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, "engines": { - "node": ">=10" + "node": ">=6" } }, - "node_modules/yargs-unparser/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, - "node_modules/yargs-unparser/node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "redis-errors": "^1.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=4" } }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "node_modules/remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", "dev": true, + "license": "ISC" + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, - "node_modules/yargs/node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": "^20.0.0 || >=22.0.0" } }, - "node_modules/yargs/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/typedoc": { + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.3.0", + "minimatch": "^10.2.5", + "yaml": "^2.9.0" + }, + "bin": { + "typedoc": "bin/typedoc" }, "engines": { - "node": ">=18" + "node": ">= 18", + "pnpm": ">= 10" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" } }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=14.17" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "license": "MIT" }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, - "license": "MIT", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">=10" + "node": ">= 14.6" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/eemeli" } } } diff --git a/package.json b/package.json index d41118a..7ad2a6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@imqueue/core", - "version": "2.0.26", + "version": "3.0.0", "description": "Simple JSON-based messaging queue for inter service communication", "keywords": [ "message-queue", @@ -12,20 +12,25 @@ ], "scripts": { "benchmark": "node benchmark -c $(( $(nproc) - 2 )) -m 100000", - "prepare": "./node_modules/.bin/tsc", - "test": "./node_modules/.bin/tsc && ./node_modules/.bin/nyc mocha --exit --timeout 10000 && ./node_modules/.bin/nyc report --reporter=text-lcov", - "test-fast": "./node_modules/.bin/tsc && ./node_modules/.bin/nyc mocha --exit --timeout 10000 && /usr/bin/env node -e \"import('open').then(open => open.default('file://`pwd`/coverage/index.html', { wait: false }))\"", - "test-local": "export COVERALLS_REPO_TOKEN=$IMQ_COVERALLS_TOKEN && npm test && /usr/bin/env node -e \"import('open').then(open => open.default('https://coveralls.io/github/imqueue/imq', { wait: false }))\"", + "clean-compiled": "npm run clean-js && npm run clean-typedefs && npm run clean-maps", + "build": "npm run clean-compiled && tsc", + "prepare": "npm run build", + "lint": "oxlint", + "format": "oxfmt \"index.ts\" \"src/**/*.ts\" \"test/**/*.ts\" \"benchmark/**/*.ts\"", + "format:check": "oxfmt --check \"index.ts\" \"src/**/*.ts\" \"test/**/*.ts\" \"benchmark/**/*.ts\"", + "test": "npm run build && node --test --test-timeout=15000 $(find test -name '*.spec.js')", + "test-coverage": "npm run build && node --enable-source-maps --test --experimental-test-coverage --test-timeout=15000 $(find test -name '*.spec.js')", + "test-lcov": "npm run build && mkdir -p coverage && node --enable-source-maps --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-timeout=15000 $(find test -name '*.spec.js'); node scripts/strip-comment-coverage.mjs coverage/lcov.info", + "test-coverage-html": "npm run test-lcov; genhtml coverage/lcov.info --output-directory coverage/html --ignore-errors inconsistent,corrupt,format,mismatch && echo \"Coverage report: file://$(pwd)/coverage/html/index.html\"", "test-dev": "npm run test && npm run clean-js && npm run clean-typedefs && npm run clean-maps", - "test-coverage": "cat ./coverage/lcov.info | CODECLIMATE_API_HOST=https://codebeat.co/webhooks/code_coverage CODECLIMATE_REPO_TOKEN=85bb2a18-4ebb-4e48-a2ce-92b7bf438b1a ./node_modules/.bin/codeclimate-test-reporter", "clean-typedefs": "find . -name '*.d.ts' -not -wholename '*node_modules*' -not -wholename '*generator*' -type f -delete", "clean-maps": "find . -name '*.js.map' -not -wholename '*node_modules*' -not -wholename '*generator*' -type f -delete", "clean-js": "find . -name '*.js' -not -wholename '*node_modules*' -not -wholename '*generator*' -type f -delete", - "clean-tests": "rm -rf .nyc_output coverage", + "clean-tests": "rm -rf .nyc_output coverage .agent-out", "clean-doc": "rm -rf docs", "clean-benchmark": "rm -rf benchmark-result", "clean": "npm run clean-tests && npm run clean-typedefs && npm run clean-maps && npm run clean-js && npm run clean-doc && npm run clean-benchmark", - "doc": "rm -rf docs && typedoc --excludePrivate --excludeExternals --hideGenerator --exclude \"**/+(test|node_modules|docs|coverage|benchmark|.nyc_output)/**/*\" --out ./docs . && /usr/bin/env node -e \"import('open').then(open => open.default('file://`pwd`/docs/index.html',{wait:false}))\"" + "doc": "typedoc && echo \"Docs generated: file://$(pwd)/docs/index.html\"" }, "repository": { "type": "git", @@ -38,69 +43,17 @@ "author": "imqueue.com (https://imqueue.com)", "license": "GPL-3.0-only", "dependencies": { - "ioredis": "^5.8.2" + "ioredis": "^5.11.1" }, "devDependencies": { - "@eslint/js": "^9.33.0", - "@types/chai": "^5.2.2", - "@types/eslint__eslintrc": "^2.1.2", - "@types/mocha": "^10.0.0", "@types/mock-require": "^3.0.0", - "@types/node": "^24.2.1", - "@types/sinon": "^17.0.4", - "@types/yargs": "^17.0.33", - "@typescript-eslint/eslint-plugin": "^8.39.0", - "@typescript-eslint/parser": "^8.39.0", - "@typescript-eslint/typescript-estree": "^8.39.0", - "chai": "^5.2.1", - "coveralls-next": "^5.0.0", - "eslint": "^9.33.0", - "eslint-plugin-jsdoc": "^52.0.4", - "mocha": "^11.7.1", - "mocha-lcov-reporter": "^1.3.0", + "@types/node": "^24.9.1", "mock-require": "^3.0.3", - "nyc": "^17.1.0", - "open": "^10.2.0", - "reflect-metadata": "^0.2.2", - "sinon": "^21.0.0", - "source-map-support": "^0.5.21", - "ts-node": "^10.9.2", - "typedoc": "^0.28.9", - "typescript": "^5.9.2", - "yargs": "^18.0.0" + "oxfmt": "0.57.0", + "oxlint": "1.72.0", + "typedoc": "^0.28.20", + "typescript": "^6.0.3" }, "main": "index.js", - "typescript": { - "definitions": "index.d.ts" - }, - "mocha": { - "require": [ - "ts-node/register", - "source-map-support/register" - ], - "recursive": true, - "bail": true, - "full-trace": true, - "exit": true, - "timeout": 10000 - }, - "nyc": { - "check-coverage": false, - "extension": [ - ".ts" - ], - "exclude": [ - "**/*.d.ts", - "**/test/**" - ], - "require": [ - "ts-node/register" - ], - "reporter": [ - "html", - "text", - "text-summary", - "lcovonly" - ] - } + "types": "index.d.ts" } diff --git a/scripts/strip-comment-coverage.mjs b/scripts/strip-comment-coverage.mjs new file mode 100644 index 0000000..6dd6926 --- /dev/null +++ b/scripts/strip-comment-coverage.mjs @@ -0,0 +1,397 @@ +/*! + * Coverage post-processor + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +/** + * Node's built-in coverage (`--experimental-test-coverage --enable-source-maps`) + * reports every non-code line of a `.ts` source as uncovered, because comment + * and blank lines have no source-map mappings and therefore fall outside every + * covered V8 byte-range. That paints the license header and each JSDoc block + * red in the HTML report and pushes per-file line% far below reality. + * + * Node's reporter also emits records that the (strict) genhtml LCOV 2.x tool + * rejects — function entries with `undefined` line numbers or synthetic, + * duplicated names (``, and decorated methods all + * mis-named `has`/`get`), plus `BRDA:undefined,...` branch records. Left in, + * they produce a wall of warnings and a fatal "unexpected category" error. + * + * This script rewrites an lcov file in place: it drops the `DA`/`BRDA` records + * for lines that contain no executable code (comments and blank lines), drops + * all function records and malformed branch records, then recomputes the + * `LF`/`LH`/`BRF`/`BRH` totals. It uses the TypeScript scanner (already a dev + * dependency) to locate comments reliably — comment markers inside strings, + * template literals and regexes are not misdetected. The result renders in + * genhtml with accurate line + branch coverage and no warnings or errors. + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import ts from 'typescript'; + +const lcovPath = process.argv[2] || 'coverage/lcov.info'; + +if (!existsSync(lcovPath)) { + console.warn(`strip-comment-coverage: ${lcovPath} not found, skipping`); + process.exit(0); +} + +/** + * Returns the set of 1-based line numbers in the given source that hold no + * executable code — i.e. blank lines and lines whose only content is a comment. + * + * @param {string} text + * @returns {Set} + */ +function nonCodeLines(text) { + const commentChars = new Uint8Array(text.length); + const scanner = ts.createScanner( + ts.ScriptTarget.Latest, + /* skipTrivia */ false, + ts.LanguageVariant.Standard, + text, + ); + + let token = scanner.scan(); + + while (token !== ts.SyntaxKind.EndOfFileToken) { + if ( + token === ts.SyntaxKind.SingleLineCommentTrivia || + token === ts.SyntaxKind.MultiLineCommentTrivia + ) { + const start = scanner.getTokenStart(); + const end = scanner.getTokenEnd(); + + for (let i = start; i < end; i++) { + commentChars[i] = 1; + } + } + + token = scanner.scan(); + } + + const lines = text.split('\n'); + const result = new Set(); + let pos = 0; + + for (let ln = 0; ln < lines.length; ln++) { + const line = lines[ln]; + let hasCode = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + + if (ch === ' ' || ch === '\t' || ch === '\r') { + continue; + } + + if (!commentChars[pos + i]) { + hasCode = true; + break; + } + } + + if (!hasCode) { + result.add(ln + 1); + } + + pos += line.length + 1; // account for the split '\n' + } + + return result; +} + +/** + * Returns the set of 1-based lines occupied by top-level import statements. + * Imports compile to `require(...)` that runs on module load, so they are + * always executed, yet Node's source-mapped coverage frequently mis-reports + * them as uncovered (the mapping lands outside the covered byte-range). Treating + * them as non-coverable corrects that artifact rather than hiding real logic. + * + * @param {string} text + * @returns {Set} + */ +function importLines(text) { + const sf = ts.createSourceFile( + 'x.ts', + text, + ts.ScriptTarget.Latest, + false, + ); + const result = new Set(); + + for (const stmt of sf.statements) { + if ( + ts.isImportDeclaration(stmt) || + ts.isImportEqualsDeclaration(stmt) + ) { + const from = sf.getLineAndCharacterOfPosition(stmt.getStart(sf)).line; + const to = sf.getLineAndCharacterOfPosition(stmt.getEnd()).line; + + for (let line = from; line <= to; line++) { + result.add(line + 1); + } + } + } + + return result; +} + +const B64 = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const B64MAP = new Map([...B64].map((c, i) => [c, i])); + +/** + * Decodes one base64 VLQ source-map segment into its integer fields. + * + * @param {string} seg + * @returns {number[]} + */ +function decodeVLQ(seg) { + const out = []; + let shift = 0; + let value = 0; + + for (const ch of seg) { + const digit = B64MAP.get(ch); + + if (digit === undefined) { + break; + } + + value += (digit & 31) << shift; + + if (digit & 32) { + shift += 5; + } else { + out.push(value & 1 ? -(value >> 1) : value >> 1); + value = 0; + shift = 0; + } + } + + return out; +} + +/** + * Returns the set of 1-based source lines that produced generated JavaScript, + * read from the file's `.js.map`. These are exactly the coverable lines — every + * other line (comments, blanks, type-only declarations, interfaces) emits no + * code and can never be executed. Returns null when no source map is present. + * + * @param {string} sourceFile - path of the .ts source (e.g. src/RedisQueue.ts) + * @returns {Set | null} + */ +function emittedLines(sourceFile) { + const mapPath = sourceFile.replace(/\.ts$/, '.js.map'); + + if (!existsSync(mapPath)) { + return null; + } + + let map; + + try { + map = JSON.parse(readFileSync(mapPath, 'utf8')); + } catch { + return null; + } + + if (typeof map.mappings !== 'string') { + return null; + } + + const lines = new Set(); + let srcIndex = 0; + let srcLine = 0; + + for (const group of map.mappings.split(';')) { + for (const seg of group.split(',')) { + if (!seg) { + continue; + } + + const fields = decodeVLQ(seg); + + // [genCol, srcIndex, srcLine, srcCol, nameIndex]; <4 fields = no + // source position for this segment + if (fields.length >= 4) { + srcIndex += fields[1]; + srcLine += fields[2]; + + if (srcIndex === 0) { + lines.add(srcLine + 1); // map lines are 0-based + } + } + } + } + + return lines; +} + +const coverableCache = new Map(); + +/** + * Resolves how to filter a source file's line records: + * - `{ mode: 'keep', set }` — keep only lines in `set` (source-map driven); + * - `{ mode: 'drop', set }` — drop lines in `set` (comment-scanner fallback). + * + * @param {string} sourceFile + * @returns {{ mode: 'keep' | 'drop', set: Set }} + */ +function coverableInfo(sourceFile) { + if (coverableCache.has(sourceFile)) { + return coverableCache.get(sourceFile); + } + + const emitted = sourceFile ? emittedLines(sourceFile) : null; + let info; + + if (emitted && existsSync(sourceFile)) { + // A line is coverable only if it emitted JS (has a source-map mapping) + // AND is not a comment/blank AND is not an import statement. The comment + // check is required because `removeComments: false` keeps license/JSDoc + // comments in the output, so they carry mappings too; the mapping check + // drops type-only lines (interfaces, type aliases) which emit nothing; + // the import check drops the mis-mapped import artifact. + const text = readFileSync(sourceFile, 'utf8'); + const nonCode = nonCodeLines(text); + const imports = importLines(text); + const keep = new Set( + [...emitted].filter( + line => !nonCode.has(line) && !imports.has(line), + ), + ); + info = { mode: 'keep', set: keep }; + } else if (sourceFile && existsSync(sourceFile)) { + const text = readFileSync(sourceFile, 'utf8'); + const skip = nonCodeLines(text); + + for (const line of importLines(text)) { + skip.add(line); + } + + info = { mode: 'drop', set: skip }; + } else { + info = { mode: 'drop', set: new Set() }; + } + + coverableCache.set(sourceFile, info); + + return info; +} + +let strippedLines = 0; + +/** + * Rewrites a single lcov record so that genhtml accepts it without warnings or + * errors. It: + * - drops DA/BRDA entries on non-code (comment/blank) lines; + * - drops all function records (FN/FNDA/FNF/FNH) — Node's source-mapped + * function detection is unreliable here (undefined line numbers, mis-named + * and duplicated entries), which genhtml rejects, so line + branch coverage + * is kept instead; + * - drops malformed branch records such as `BRDA:undefined,...`; + * - recomputes the LF/LH/BRF/BRH totals. + * + * @param {string[]} recordLines - record body, excluding the end_of_record line + * @returns {string} + */ +function processRecord(recordLines) { + const sfLine = recordLines.find(line => line.startsWith('SF:')); + const sourceFile = sfLine ? sfLine.slice(3).trim() : ''; + const info = coverableInfo(sourceFile); + + // true when a source line produces no executable code and must be dropped + const drop = n => (info.mode === 'keep' ? !info.set.has(n) : info.set.has(n)); + + let lf = 0; + let lh = 0; + let brf = 0; + let brh = 0; + + const kept = recordLines.filter(line => { + // drop all function records — unreliable under source maps + if (/^(FN:|FNDA:|FNF:|FNH:)/.test(line)) { + return false; + } + + const da = line.match(/^DA:(\d+),(\d+)/); + + if (da) { + if (drop(+da[1])) { + strippedLines++; + + return false; + } + + lf++; + + if (+da[2] > 0) { + lh++; + } + + return true; + } + + if (line.startsWith('BRDA:')) { + const brda = line.match(/^BRDA:(\d+),\d+,\d+,(\d+|-)$/); + + // drop malformed (e.g. BRDA:undefined,...) or non-code branches + if (!brda || drop(+brda[1])) { + return false; + } + + brf++; + + if (brda[2] !== '-' && +brda[2] > 0) { + brh++; + } + + return true; + } + + // drop the old totals — they are recomputed below + return !/^(LF|LH|BRF|BRH):/.test(line); + }); + + return ( + `${kept.join('\n')}\n` + + `LF:${lf}\nLH:${lh}\nBRF:${brf}\nBRH:${brh}\nend_of_record\n` + ); +} + +const output = []; +let record = []; + +for (const line of readFileSync(lcovPath, 'utf8').split('\n')) { + if (line === 'end_of_record') { + output.push(processRecord(record)); + record = []; + } else if (line !== '') { + record.push(line); + } +} + +writeFileSync(lcovPath, output.join('')); +console.info( + `strip-comment-coverage: removed ${strippedLines} comment/blank line ` + + `entries from ${lcovPath}`, +); diff --git a/src/ClusterManager.ts b/src/ClusterManager.ts index 86bbab5..eeff007 100644 --- a/src/ClusterManager.ts +++ b/src/ClusterManager.ts @@ -22,14 +22,12 @@ * to get commercial licensing options. */ import { IMessageQueueConnection, IServerInput } from './IMessageQueue'; -import { uuid } from './uuid'; +import { randomUUID } from 'node:crypto'; export interface ICluster { - add: (server: IServerInput) => T; + add: (server: IServerInput) => IMessageQueueConnection; remove: (server: IServerInput) => void; - find: ( - server: IServerInput, - ) => T | undefined; + find: (server: IServerInput) => IMessageQueueConnection | undefined; } export interface InitializedCluster extends ICluster { @@ -42,20 +40,31 @@ export abstract class ClusterManager { protected constructor() {} public init(cluster: ICluster): InitializedCluster { - const initializedCluster = Object.assign( - cluster, - { id: uuid() }, - ) as InitializedCluster; + const initializedCluster: InitializedCluster = { + ...cluster, + id: randomUUID(), + }; this.clusters.push(initializedCluster); return initializedCluster; } - public async anyCluster( + /** + * Applies the given callback to every registered cluster. Each cluster + * is handled independently: a callback that throws (synchronously or + * asynchronously) for one cluster never prevents the remaining clusters + * from being processed. + * + * @param {(cluster: InitializedCluster) => Promise | void} fn + * @returns {Promise} + */ + public async forEachCluster( fn: (cluster: InitializedCluster) => Promise | void, ): Promise { - await Promise.all(this.clusters.map(cluster => fn(cluster))); + await Promise.allSettled( + this.clusters.map(async cluster => fn(cluster)), + ); } public async remove( @@ -67,9 +76,9 @@ export abstract class ClusterManager { this.clusters = this.clusters.filter(cluster => cluster.id !== id); if ( - this.clusters.length === 0 - && destroy - && typeof this.destroy === 'function' + this.clusters.length === 0 && + destroy && + typeof this.destroy === 'function' ) { await this.destroy(); } diff --git a/src/ClusteredRedisQueue.ts b/src/ClusteredRedisQueue.ts index 739535e..52a7cc6 100644 --- a/src/ClusteredRedisQueue.ts +++ b/src/ClusteredRedisQueue.ts @@ -21,10 +21,10 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import { EventEmitter } from 'events'; +import { EventEmitter } from 'node:events'; +import { InitializedCluster } from './ClusterManager'; +import { buildOptions, copyEventEmitter } from './helpers'; import { - buildOptions, - copyEventEmitter, DEFAULT_IMQ_OPTIONS, EventMap, ILogger, @@ -36,7 +36,13 @@ import { JsonObject, RedisQueue, } from '.'; -import { InitializedCluster } from './ClusterManager'; + +/** + * Time (ms) send() waits for the first cluster server to become available + * before rejecting, when the cluster is still empty. Configurable via the + * IMQ_SEND_INIT_TIMEOUT environment variable, defaults to 30,000. + */ +const SEND_INIT_TIMEOUT = +(process.env.IMQ_SEND_INIT_TIMEOUT || 0) || 30000; interface ClusterServer extends IMessageQueueConnection { imq?: RedisQueue; @@ -46,18 +52,18 @@ interface ClusterState { started: boolean; subscription: { channel: string; - handler: (data: JsonObject) => any; + handler: (data: JsonObject) => void; } | null; } /** * Class ClusteredRedisQueue - * Implements the possibility to scale queues horizontally between several + Implements the possibility to scale queues horizontally between several * redis instances. */ -export class ClusteredRedisQueue implements IMessageQueue, - EventEmitter { - +export class ClusteredRedisQueue + implements IMessageQueue, EventEmitter +{ /** * Logger instance associated with this queue instance * @@ -92,7 +98,6 @@ export class ClusteredRedisQueue implements IMessageQueue, * * @type {IMessageQueueConnection[]} */ - // tslint:disable-next-line:completed-docs private servers: ClusterServer[] = []; /** @@ -102,7 +107,11 @@ export class ClusteredRedisQueue implements IMessageQueue, */ private currentQueue: number = 0; - // noinspection TypeScriptFieldCanBeMadeReadonly + /** + * Time (ms) send() waits for the first server when the cluster is empty + */ + private readonly sendInitTimeout: number = SEND_INIT_TIMEOUT; + /** * Total length of RedisQueue instances * @@ -136,31 +145,30 @@ export class ClusteredRedisQueue implements IMessageQueue, /** * Class constructor * - * @constructor * @param {string} name * @param {Partial} options - * @param {IMQMode} [mode] + * @param {IMQMode} [_mode] */ public constructor( public name: string, options?: Partial, - mode: IMQMode = IMQMode.BOTH, + _mode: IMQMode = IMQMode.BOTH, ) { this.templateEmitter = new EventEmitter(); this.clusterEmitter = new EventEmitter(); this.options = buildOptions(DEFAULT_IMQ_OPTIONS, options); - // istanbul ignore next this.logger = this.options.logger || console; if (!this.options.cluster && !this.options.clusterManagers?.length) { - throw new TypeError('ClusteredRedisQueue: cluster ' + - 'configuration is missing!'); + throw new TypeError( + 'ClusteredRedisQueue: cluster ' + 'configuration is missing!', + ); } this.mqOptions = { ...this.options }; - const cluster = [...this.mqOptions.cluster || []]; + const cluster = [...(this.mqOptions.cluster || [])]; delete this.mqOptions.cluster; @@ -172,11 +180,13 @@ export class ClusteredRedisQueue implements IMessageQueue, this.verbose('Initializing cluster managers...'); for (const manager of this.options.clusterManagers) { - this.initializedClusters.push(manager.init({ - add: this.addServer.bind(this), - remove: this.removeServer.bind(this), - find: this.findServer.bind(this), - })); + this.initializedClusters.push( + manager.init({ + add: this.addServer.bind(this), + remove: this.removeServer.bind(this), + find: this.findServer.bind(this), + }), + ); } } } @@ -190,8 +200,10 @@ export class ClusteredRedisQueue implements IMessageQueue, public async start(): Promise { this.state.started = true; - return await this.batch('start', - 'Starting clustered redis message queue...'); + return await this.batch( + 'start', + 'Starting clustered redis message queue...', + ); } /** @@ -203,20 +215,22 @@ export class ClusteredRedisQueue implements IMessageQueue, public async stop(): Promise { this.state.started = false; - return await this.batch('stop', - 'Stopping clustered redis message queue...'); + return await this.batch( + 'stop', + 'Stopping clustered redis message queue...', + ); } /** - * Sends a message to given queue name with the given data. + * Sends a message to a given queue name with the given data. * Supposed to be an async function. * - * @param {string} toQueue - queue name to which message should be sent to + * @param {string} toQueue - queue name to which a message should be sent to * @param {JsonObject} message - message data * @param {number} [delay] - if specified, a message will be handled in the - * target queue after a specified period of time in milliseconds. + target queue after a specified period of time in milliseconds. * @param {(err: Error) => void} [errorHandler] - callback called only when - * internal error occurs during message send execution. + internal error occurs during message send execution. * @returns {Promise} - message identifier */ public async send( @@ -226,29 +240,84 @@ export class ClusteredRedisQueue implements IMessageQueue, errorHandler?: (err: Error) => void, ): Promise { if (!this.imqLength) { - return await new Promise(resolve => this.clusterEmitter.once( - 'initialized', - async ({ imq }) => { - resolve(await imq.send( - toQueue, - message, - delay, - errorHandler, - )); - }, - )); + return this.sendWhenInitialized( + toQueue, + message, + delay, + errorHandler, + ); } - if (this.currentQueue >= this.imqLength) { - this.currentQueue = 0; + const imq = this.selectQueue(); + + return imq.send(toQueue, message, delay, errorHandler); + } + + /** + * Picks the next queue for a round-robin send, preferring an instance + * whose redis connection is currently ready so messages are not routed + * to a host that is known to be down. Falls back to the plain + * round-robin pick when no instance reports are ready. + * + * @returns {RedisQueue} + */ + private selectQueue(): RedisQueue { + const count = this.imqLength; + const start = this.currentQueue % count; + + for (let offset = 0; offset < count; offset++) { + const index = (start + offset) % count; + const candidate = this.imqs[index]; + + if (candidate.available) { + this.currentQueue = index + 1; + + return candidate; + } } - const imq: any = this.imqs[this.currentQueue]; - const id = await imq.send(toQueue, message, delay, errorHandler); + this.currentQueue = start + 1; - this.currentQueue++; + return this.imqs[start]; + } - return id; + /** + * Sends a message once the first cluster server becomes available. + * Rejects (rather than hanging forever) if none appears within the + * configured timeout and propagates any sent failure. + * + * @returns {Promise} + */ + private sendWhenInitialized( + toQueue: string, + message: JsonObject, + delay?: number, + errorHandler?: (err: Error) => void, + ): Promise { + return new Promise((resolve, reject) => { + const onInitialized = ({ imq }: { imq: RedisQueue }): void => { + clearTimeout(timer); + imq.send(toQueue, message, delay, errorHandler).then( + resolve, + reject, + ); + }; + + const timer = setTimeout(() => { + this.clusterEmitter.removeListener( + 'initialized', + onInitialized, + ); + reject( + new Error( + 'ClusteredRedisQueue: no cluster server became ' + + 'available to send the message', + ), + ); + }, this.sendInitTimeout); + + this.clusterEmitter.once('initialized', onInitialized); + }); } /** @@ -261,21 +330,22 @@ export class ClusteredRedisQueue implements IMessageQueue, public async destroy(): Promise { this.state.started = false; - await this.batch('destroy', - 'Destroying clustered redis message queue...'); + await this.batch( + 'destroy', + 'Destroying clustered redis message queue...', + ); if (!this.options.clusterManagers?.length) { return; } - for await (const manager of this.options.clusterManagers) { - for await (const cluster of this.initializedClusters) { + for (const manager of this.options.clusterManagers) { + for (const cluster of this.initializedClusters) { await manager.remove(cluster); } } } - // noinspection JSUnusedGlobalSymbols /** * Clears queue data in queue host application. * Supposed to be an async function. @@ -283,8 +353,10 @@ export class ClusteredRedisQueue implements IMessageQueue, * @returns {Promise} */ public async clear(): Promise { - return await this.batch('clear', - 'Clearing clustered redis message queue...'); + return await this.batch( + 'clear', + 'Clearing clustered redis message queue...', + ); } public async queueLength(): Promise { @@ -301,27 +373,31 @@ export class ClusteredRedisQueue implements IMessageQueue, private verbose(message: string): void { if (this.options.verbose) { - this.logger.info(`[IMQ-CORE][ClusteredRedisQueue][${ - this.name - }]: ${ message }`); + this.logger.info( + `[IMQ-CORE][ClusteredRedisQueue][${this.name}]: ${message}`, + ); } } /** * Batch imq action processing on all registered imqs at once * - * @access private * @param {string} action * @param {string} message - * @return {Promise} + * @returns {Promise} */ - private async batch(action: string, message: string): Promise { + private async batch( + action: 'start' | 'stop' | 'destroy' | 'clear', + message: string, + ): Promise { this.logger.info(message); - const promises = []; + const promises: Promise[] = []; for (const imq of this.imqs) { - promises.push(imq[action]()); + const run = imq[action] as () => Promise; + + promises.push(run.call(imq)); } await Promise.all(promises); @@ -329,139 +405,120 @@ export class ClusteredRedisQueue implements IMessageQueue, return this; } - /* tslint:disable */ // EventEmitter interface - // istanbul ignore next - public on(...args: any[]) { + /** + * Applies the named EventEmitter method to every underlying emitter, + * forwarding the call across the whole cluster. Dispatch is reflective + * (method chosen by name), so a single contained cast bridges the dynamic + * call while the public method signatures below stay fully typed. + * + * @template K - EventEmitter method name + * @param {K} method - name of the EventEmitter method to invoke + * @param {any[]} args - arguments to pass to the method + * @returns {unknown[]} - results from each emitter call + */ + private applyToEmitters( + method: K, + args: any[], + ): unknown[] { + const results: unknown[] = []; + for (const imq of this.eventEmitters()) { - imq.on.apply(imq, args); + const fn = imq[method] as unknown as (...a: any[]) => unknown; + + results.push(fn.apply(imq, args)); } - return this; + return results; } - // istanbul ignore next - // noinspection JSUnusedGlobalSymbols - public off(...args: any[]) { - for (const imq of this.eventEmitters()) { - imq.off.apply(imq, args); - } + public on(...args: any[]): this { + this.applyToEmitters('on', args); return this; } - // istanbul ignore next - public once(...args: any[]) { - for (const imq of this.eventEmitters()) { - imq.once.apply(imq, args); - } + public off(...args: any[]): this { + this.applyToEmitters('off', args); return this; } - // istanbul ignore next - public addListener(...args: any[]) { - for (const imq of this.eventEmitters()) { - imq.addListener.apply(imq, args); - } + public once(...args: any[]): this { + this.applyToEmitters('once', args); return this; } - // istanbul ignore next - public removeListener(...args: any[]) { - for (const imq of this.eventEmitters()) { - imq.removeListener.apply(imq, args); - } + public addListener(...args: any[]): this { + this.applyToEmitters('addListener', args); return this; } - // istanbul ignore next - public removeAllListeners(...args: any[]) { - for (const imq of this.eventEmitters()) { - imq.removeAllListeners.apply(imq, args); - } + public removeListener(...args: any[]): this { + this.applyToEmitters('removeListener', args); return this; } - // istanbul ignore next - public prependListener(...args: any[]) { - for (const imq of this.eventEmitters()) { - imq.prependListener.apply(imq, args); - } + public removeAllListeners(...args: any[]): this { + this.applyToEmitters('removeAllListeners', args); return this; } - // istanbul ignore next - public prependOnceListener(...args: any[]) { - for (const imq of this.eventEmitters()) { - imq.prependOnceListener.apply(imq, args); - } + public prependListener(...args: any[]): this { + this.applyToEmitters('prependListener', args); return this; } - // istanbul ignore next - public setMaxListeners(...args: any[]) { - for (const imq of this.eventEmitters()) { - imq.setMaxListeners.apply(imq, args); - } + public prependOnceListener(...args: any[]): this { + this.applyToEmitters('prependOnceListener', args); return this; } - // istanbul ignore next - public listeners(...args: any[]) { - let listeners: any[] = []; - - for (const imq of this.eventEmitters()) { - listeners = listeners.concat(imq.listeners.apply(imq, args)); - } + public setMaxListeners(...args: any[]): this { + this.applyToEmitters('setMaxListeners', args); - return listeners; + return this; } - // istanbul ignore next - public rawListeners(...args: any[]) { - let rawListeners: any[] = []; - - for (const imq of this.eventEmitters()) { - rawListeners = rawListeners.concat( - imq.rawListeners.apply(imq, args), - ); - } + // Aggregates listeners across every underlying emitter, so the result is + // an untyped union rather than Node's per-emitter conditional listener type. + public listeners(...args: any[]): any[] { + return this.applyToEmitters('listeners', args).flat(); + } - return rawListeners; + public rawListeners(...args: any[]): any[] { + return this.applyToEmitters('rawListeners', args).flat(); } - // istanbul ignore next - public getMaxListeners() { + public getMaxListeners(): number { return this.templateEmitter.getMaxListeners(); } - // istanbul ignore next - public emit(...args: any[]) { - for (const imq of this.eventEmitters()) { - imq.emit.apply(imq, args); - } + public emit(...args: any[]): boolean { + this.applyToEmitters('emit', args); return true; } - // istanbul ignore next - public eventNames(...args: any[]) { - return this.templateEmitter.eventNames.apply(this.imqs[0], args); + public eventNames(): (keyof EventMap)[] { + const source = this.imqs[0] || this.templateEmitter; + + return source.eventNames() as (keyof EventMap)[]; } - // istanbul ignore next - public listenerCount(...args: any[]) { - return this.templateEmitter.listenerCount.apply(this.imqs[0], args); + public listenerCount(...args: any[]): number { + const source = this.imqs[0] || this.templateEmitter; + const fn = source.listenerCount as (...a: any[]) => number; + + return fn.apply(source, args); } - // istanbul ignore next public async publish(data: JsonObject, toName?: string): Promise { const promises: Array> = []; @@ -472,10 +529,9 @@ export class ClusteredRedisQueue implements IMessageQueue, await Promise.all(promises); } - // istanbul ignore next public async subscribe( channel: string, - handler: (data: JsonObject) => any, + handler: (data: JsonObject) => void, ): Promise { this.state.subscription = { channel, handler }; @@ -488,7 +544,6 @@ export class ClusteredRedisQueue implements IMessageQueue, await Promise.all(promises); } - // istanbul ignore next public async unsubscribe(): Promise { this.state.subscription = null; @@ -508,7 +563,7 @@ export class ClusteredRedisQueue implements IMessageQueue, * @returns {void} */ protected addServer(server: IServerInput): ClusterServer { - this.verbose(`Adding new server: ${ JSON.stringify(server) }`); + this.verbose(`Adding new server: ${JSON.stringify(server)}`); return this.addServerWithQueueInitializing(server, true); } @@ -520,7 +575,7 @@ export class ClusteredRedisQueue implements IMessageQueue, * @returns {void} */ protected removeServer(server: IServerInput): void { - this.verbose(`Removing the server: ${ JSON.stringify(server) }`); + this.verbose(`Removing the server: ${JSON.stringify(server)}`); const remove = this.findServer(server); @@ -532,17 +587,18 @@ export class ClusteredRedisQueue implements IMessageQueue, if (imqToRemove) { this.imqs = this.imqs.filter( - imq => imqToRemove.redisKey !== imq.redisKey + imq => imqToRemove.redisKey !== imq.redisKey, ); - imqToRemove.destroy().catch(); + imqToRemove + .destroy() + .catch((err: unknown) => + this.verbose(`Error destroying removed server: ${err}`), + ); } this.imqLength = this.imqs.length; this.servers = this.servers.filter( - existing => !ClusteredRedisQueue.matchServers( - existing, - server, - ), + existing => !ClusteredRedisQueue.matchServers(existing, server), ); this.clusterEmitter.emit('remove', { server: remove, @@ -569,6 +625,8 @@ export class ClusteredRedisQueue implements IMessageQueue, const opts = { ...this.mqOptions, ...newServer }; const imq = new RedisQueue(this.name, opts); + copyEventEmitter(this.templateEmitter, imq); + if (initializeQueue) { this.initializeQueue(imq).then(() => { this.clusterEmitter.emit('initialized', { @@ -593,13 +651,12 @@ export class ClusteredRedisQueue implements IMessageQueue, } private async initializeQueue(imq: RedisQueue): Promise { - copyEventEmitter(this.templateEmitter, imq); - this.verbose(`Initializing queue with state: ${ - JSON.stringify(this.state) - }`); + this.verbose( + `Initializing queue with state: ${JSON.stringify(this.state)}`, + ); if (this.state.started) { - await imq.start(); + await imq.start(); } if (this.state.subscription) { @@ -611,11 +668,8 @@ export class ClusteredRedisQueue implements IMessageQueue, } private findServer(server: IServerInput): ClusterServer | undefined { - return this.servers.find( - existing => ClusteredRedisQueue.matchServers( - existing, - server, - ), + return this.servers.find(existing => + ClusteredRedisQueue.matchServers(existing, server), ); } @@ -623,8 +677,8 @@ export class ClusteredRedisQueue implements IMessageQueue, source: IServerInput, target: IServerInput, ): boolean { - const sameAddress = target.host === source.host - && target.port === source.port; + const sameAddress = + target.host === source.host && target.port === source.port; if (!target.id && !source.id) { return sameAddress; diff --git a/src/IMessageQueue.ts b/src/IMessageQueue.ts index 51d714b..6b8aa81 100644 --- a/src/IMessageQueue.ts +++ b/src/IMessageQueue.ts @@ -21,17 +21,23 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import { EventEmitter } from 'events'; +import { EventEmitter } from 'node:events'; import { IMQMode } from './IMQMode'; import { ClusterManager } from './ClusterManager'; -export { EventEmitter } from 'events'; +export { EventEmitter } from 'node:events'; /** * Represents any JSON-serializable value */ -export type AnyJson = boolean | number | string | null | undefined | - JsonArray | JsonObject; +export type AnyJson = + | boolean + | number + | string + | null + | undefined + | JsonArray + | JsonObject; /** * Represents JSON serializable object @@ -45,14 +51,6 @@ export interface JsonObject { */ export interface JsonArray extends Array {} -/** - * Alias for JsonObject, legacy, outdated, deprecated, try to avoid using. - * Stands here for backward-compatibility only - * - * @deprecated - */ -export type IJson = JsonObject; - /** * Logger interface */ @@ -60,34 +58,34 @@ export interface ILogger { /** * Log level function * - * @param {...any[]} args + * @param {...unknown[]} args */ - log(...args: any[]): void; + log(...args: unknown[]): void; /** * Info level function * - * @param {...any[]} args + * @param {...unknown[]} args */ - info(...args: any[]): void; + info(...args: unknown[]): void; /** * Warning level function * - * @param {...any[]} args + * @param {...unknown[]} args */ - warn(...args: any[]): void; + warn(...args: unknown[]): void; /** * Error level function * - * @param {...any[]} args + * @param {...unknown[]} args */ - error(...args: any[]): void; + error(...args: unknown[]): void; } /** - * Defines message format. + * Defines a message format. * * @type {IMessage} */ @@ -195,12 +193,11 @@ export interface IMQOptions extends Partial { cleanup: boolean; /** - * Defines cleanup pattern for the name of the queue - * which should be removed during cleanup processing + * Cleanup pattern for queue names that should be removed during cleanup * * @type {string} */ - cleanupFilter: string, + cleanupFilter: string; /** * Message queue vendor @@ -217,45 +214,42 @@ export interface IMQOptions extends Partial { prefix?: string; /** - * Logger defined to be used within message queue in runtime + * Logger instance to use for message queue logging at runtime * * @type {ILogger} */ logger?: ILogger; /** - * Watcher check delay period. This is used by a queue watcher - * agent to make sure at least one watcher is available for - * queue operations. + * Delay period (in milliseconds) between watcher availability checks. + * Used to ensure at least one watcher is available for queue operations. * * @type {number} */ watcherCheckDelay?: number; /** - * A way to serialize message using compression. Will increase - * load to worker process but can decrease network traffic between worker - * and queue host application + * Enable message compression for serialization. Increases a worker CPU load + * but decreases network traffic between workers and the queue host. * * @type {boolean} */ useGzip?: boolean; /** - * Enables/disables safe message delivery. When safe message delivery - * is turned on it will use more complex algorithm for message handling - * by a worker process, guaranteeing that if worker fails the message will - * be delivered to another possible worker anyway. In most cases it - * is not required unless it is required by a system design. + * Enable guaranteed message delivery. When enabled, uses a more complex + * algorithm for message handling, ensuring that if a worker fails, the + * message will be delivered to another worker. Required only for systems + * that demand guaranteed delivery semantics. * * @type {boolean} */ safeDelivery?: boolean; /** - * Time-to-live of worker queues (after this time messages are back to - * main queue for handling if worker died). Only works if safeDelivery - * option enabled. + * Time-to-live (in milliseconds) for messages in worker queues. After this + * period, unacknowledged messages return to the main queue for reprocessing + * if the worker died. Only effective when safeDelivery is enabled. * * @type {number} */ @@ -276,6 +270,16 @@ export interface IMQOptions extends Partial { */ clusterManagers?: ClusterManager[]; + /** + * Enable process signal handling (SIGTERM, SIGINT, SIGABRT) by the queue. + * When enabled, the queue releases its watcher lock and exits gracefully + * on these signals. Disable if the host application manages shutdown. + * + * @default true + * @type {boolean} + */ + handleSignals?: boolean; + /** * Enables/disables verbose logging * @@ -296,8 +300,8 @@ export interface IMQOptions extends Partial { } export interface EventMap { - message: [data: any, id: string, from: string], - error: [error: Error, eventName: string], + message: [data: JsonObject, id: string, from: string]; + error: [error: Error, eventName: string]; } export type IMessageQueueConstructor = new ( @@ -311,130 +315,129 @@ export type IMessageQueueConstructor = new ( * * @example * ~~~typescript - * import { IMessageQueue, EventEmitter, uuid } from '@imqueue/core'; + * import { IMessageQueue, EventEmitter } from '@imqueue/core'; + * import { randomUUID } from 'node:crypto'; * - * class SomeMQAdapter implements IMessageQueue extends EventEmitter { - * public async start(): Promise { - * // ... implementation goes here - * return this; - * } - * public async stop(): Promise { - * // ... implementation goes here - * return this; - * } - * public async send( - * toQueue: string, - * message: JsonObject, - * delay?: number - * ): Promise { - * const messageId = uuid(); - * // ... implementation goes here - * return messageId; - * } - * public async destroy(): Promise { - * // ... implementation goes here - * } - * public async clear(): Promise { - * // ... implementation goes here - * return this; - * } + * class SomeMQAdapter extends EventEmitter implements IMessageQueue { + * public async start(): Promise { + * // ... implementation goes here + * return this; + * } + * public async stop(): Promise { + * // ... implementation goes here + * return this; + * } + * public async send( + * toQueue: string, + * message: JsonObject, + * delay?: number, + * ): Promise { + * const messageId = randomUUID(); + * // ... implementation goes here + * return messageId; + * } + * public async destroy(): Promise { + * // ... implementation goes here + * } + * public async clear(): Promise { + * // ... implementation goes here + * return this; + * } * } * ~~~ */ export interface IMessageQueue extends EventEmitter { /** - * Message event. Occurs every time queue got a message. + * Message event. Occurs every time the queue receives a message. * * @event IMessageQueue#message * @type {JsonObject} message - message data * @type {string} id - message identifier - * @type {string} from - source queue produced the message + * @type {string} from - source queue that produced the message */ /** - * Error event. Occurs every time queue caught an error. + * Error event. Occurs every time the queue encounters an error. * * @event IMessageQueue#error - * @type {Error} err - error caught by message queue + * @type {Error} err - error caught by the message queue * @type {string} code - message queue error code */ /** * Starts the messaging queue. - * Supposed to be an async function. * * @returns {Promise} */ start(): Promise; /** - * Stops the queue (should stop handle queue messages). - * Supposed to be an async function. + * Stops the queue from handling messages. * * @returns {Promise} */ stop(): Promise; /** - * Sends a message to given queue name with the given data. - * Supposed to be an async function. + * Sends a message to the specified queue with the given data. * - * @param {string} toQueue - queue name to which message should be sent to - * @param {JsonObject} message - message data - * @param {number} [delay] - if specified, message will be handled in the - * target queue after specified period of time in milliseconds. - * @param {(err: Error) => void} [errorHandler] - callback called only when - * internal error occurs during message send execution. - * @returns {Promise} - message identifier + * @param {string} toQueue - name of the destination queue + * @param {JsonObject} message - message data to send + * @param {number} [delay] - if specified, the message will be handled in + * the target queue after the specified delay in milliseconds + * @param {(err: Error) => void} [errorHandler] - callback invoked only + * when an internal error occurs during message send execution + * @returns {Promise} - the message identifier */ - send(toQueue: string, message: JsonObject, delay?: number, - errorHandler?: (err: Error) => void): Promise; + send( + toQueue: string, + message: JsonObject, + delay?: number, + errorHandler?: (err: Error) => void, + ): Promise; /** - * Creates or uses subscription channel with the given name and sets + * Creates or uses a subscription channel with the given name and sets * message handler on data receive * * @param {string} channel - channel name - * @param {(data: JsonObject) => any} handler + * @param {(data: JsonObject) => void} handler */ subscribe( channel: string, - handler: (data: JsonObject) => any, + handler: (data: JsonObject) => void, ): Promise; /** - * Closes subscription channel + * Closes the subscription channel * - * @return {Promise} + * @returns {Promise} */ unsubscribe(): Promise; /** - * Publishes data to current queue channel + * Publishes data to the current queue channel * - * If toName specified will publish to pubsub with different name. This - * can be used to implement broadcasting some messages to other subscribers - * on other pubsub channels. Different name should be in the same namespace - * (same imq prefix) + * If toName is specified, publishes to a pubsub with a different name. This + * can be used to broadcast messages to other subscribers on different pubsub + * channels. Different names must be in the same namespace (same imq prefix). * - * @param {JsonObject} data - data to publish as channel message - * @param {string} [toName] - different name of the pubsub to publish to - * @return {Promise} + * @param {JsonObject} data - data to publish as a channel message + * @param {string} [toName] - optional different pubsub name to publish to + * @returns {Promise} */ publish(data: JsonObject, toName?: string): Promise; /** - * Safely destroys current queue, unregistered all set event - * listeners and connections. - * Supposed to be an async function. + * Safely destroys the current queue, unregistering all event listeners + * and closing connections. * * @returns {Promise} */ destroy(): Promise; /** - * Clears queue data in queue host application. - * Supposed to be an async function. + * Clears all queue data from the queue host application. * * @returns {Promise} */ diff --git a/src/RedisQueue.ts b/src/RedisQueue.ts index 7b54c27..4170a34 100644 --- a/src/RedisQueue.ts +++ b/src/RedisQueue.ts @@ -21,10 +21,9 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import * as crypto from 'crypto'; -import { EventEmitter } from 'events'; -import * as os from 'os'; -import { gunzipSync as gunzip, gzipSync as gzip } from 'zlib'; +import { EventEmitter } from 'node:events'; +import { randomUUID } from 'node:crypto'; +import { hostname } from 'node:os'; import { IMessageQueue, IRedisClient, @@ -34,16 +33,52 @@ import { ILogger, IMQMode, EventMap, - buildOptions, profile, - uuid, } from '.'; +import { + buildOptions, + escapeRegExp, + randomInt, + pack, + sha1, + unpack, + envInt, +} from './helpers'; import Redis from './redis'; const RX_CLIENT_NAME = /name=(\S+)/g; const RX_CLIENT_TEST = /:(reader|writer|watcher)/; const RX_CLIENT_CLEAN = /:(reader|writer|watcher).*$/; +/** Base delay (ms) for the exponential reconnection backoff */ +const RECONNECT_BASE_DELAY = 1000; + +/** Upper cap (ms) for the exponential reconnection backoff */ +const RECONNECT_MAX_DELAY = 30000; + +/** SCAN batch size used while sweeping keys */ +const SCAN_COUNT = '1000'; + +/** + * ioredis retry strategy that disables the built-in reconnection — this + * queue performs its own capped-backoff reconnection instead. + * + * @returns {null} + */ +function noRetryStrategy(): null { + return null; +} + +/** + * Resolves after the given number of milliseconds. + * + * @param {number} ms + * @returns {Promise} + */ +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + export const DEFAULT_IMQ_OPTIONS: IMQOptions = { host: 'localhost', port: 6379, @@ -55,72 +90,37 @@ export const DEFAULT_IMQ_OPTIONS: IMQOptions = { safeDeliveryTtl: 5000, useGzip: false, watcherCheckDelay: 5000, + handleSignals: true, }; -export const IMQ_SHUTDOWN_TIMEOUT = +(process.env.IMQ_SHUTDOWN_TIMEOUT || 1000); - -/** - * Returns SHA1 hash sum of the given string - * - * @param {string} str - * @returns {string} - */ -export function sha1(str: string): string { - const sha: crypto.Hash = crypto.createHash('sha1'); - - sha.update(str); - - return sha.digest('hex'); -} +export const IMQ_SHUTDOWN_TIMEOUT = envInt('IMQ_SHUTDOWN_TIMEOUT', 1000); /** - * Returns random integer between given min and max - * - * @param {number} min - * @param {number} max - * @returns {number} + * Grace period (ms) for a graceful QUIT to complete before a channel is + * forcibly disconnected. A reader blocked on an infinite BRPOP/BLMOVE can + * never let QUIT through, so without this the socket would leak and keep + * the process alive. */ -export function intrand(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -/** - * Compress given data and returns binary string - * - * @param {any} data - * @returns {string} - */ -// istanbul ignore next -export function pack(data: any): string { - return gzip(JSON.stringify(data)).toString('binary'); -} - -/** - * Decompress binary string and returns plain data - * - * @param {string} data - * @returns {any} - */ -// istanbul ignore next -export function unpack(data: string): any { - return JSON.parse(gunzip(Buffer.from(data, 'binary')).toString()); -} +export const IMQ_CONNECTION_QUIT_TIMEOUT = envInt( + 'IMQ_CONNECTION_QUIT_TIMEOUT', + 1000, +); type RedisConnectionChannel = 'reader' | 'writer' | 'watcher' | 'subscription'; -const IMQ_REDIS_MAX_LISTENERS_LIMIT = +( - process.env.IMQ_REDIS_MAX_LISTENERS_LIMIT || 10000 +const IMQ_REDIS_MAX_LISTENERS_LIMIT = envInt( + 'IMQ_REDIS_MAX_LISTENERS_LIMIT', + 10000, ); /** * Class RedisQueue - * Implements simple messaging queue over redis. + * Implements a simple messaging queue over redis. */ -export class RedisQueue extends EventEmitter - implements IMessageQueue { - - [name: string]: any; - +export class RedisQueue + extends EventEmitter + implements IMessageQueue +{ /** * Writer connections collection * @@ -135,6 +135,27 @@ export class RedisQueue extends EventEmitter */ private static watchers: { [key: string]: IRedisClient } = {}; + /** + * Number of started queue instances per shared writer connection key + * + * @type {{}} + */ + private static writerRefs: { [key: string]: number } = {}; + + /** + * All started queue instances within the current process + * + * @type {Set} + */ + private static readonly instances: Set = new Set(); + + /** + * True when process-level signal handlers were bound + * + * @type {boolean} + */ + private static signalsBound: boolean = false; + /** * @event message (message: JsonObject, id: string, from: string) */ @@ -190,23 +211,48 @@ export class RedisQueue extends EventEmitter private watchOwner: boolean = false; /** - * Signals initialization state + * Subscription handlers registered through subscribe(). Kept to be + * able to restore the subscription after a connection replacement. * - * @type {boolean} + * @type {Array<(data: JsonObject) => void>} */ - private signalsInitialized: boolean = false; + private subscriptionHandlers: Array<(data: JsonObject) => void> = []; /** * Will store check interval reference */ - private safeCheckInterval: any; + private safeCheckInterval?: NodeJS.Timeout; + + /** + * Periodic watcher existence check interval reference + */ + private watcherCheckInterval?: NodeJS.Timeout; + + /** + * Guards overlapping watcher check runs + */ + private watcherCheckBusy: boolean = false; + + /** + * Connected client keys seen during the previous cleanup sweep. Used + * to give temporarily disconnected clients one sweep of grace before + * removing their keys. + */ + private lastConnectedKeys: string[] = []; + + /** + * True while this instance holds a reference to the shared writer + */ + private writerAcquired: boolean = false; /** * Internal per-channel reconnection state */ - private reconnectTimers: Partial> = {}; - private reconnectAttempts: Partial> - = {}; + private reconnectTimers: Partial< + Record + > = {}; + private reconnectAttempts: Partial> = + {}; private reconnecting: Partial> = {}; /** @@ -215,49 +261,48 @@ export class RedisQueue extends EventEmitter public readonly redisKey: string; /** - * LUA scripts for redis + * Lua scripts for redis * * @type {{moveDelayed: {code: string}}} */ - // tslint:disable-next-line:completed-docs - private scripts: { [name: string]: { code: string, checksum?: string } } = { + private scripts: { [name: string]: { code: string; checksum?: string } } = { moveDelayed: { - code: - 'local messages = redis.call(' + - '"zrangebyscore", KEYS[1], "-inf", ARGV[1]) ' + - 'local count = table.getn(messages) ' + - 'local message ' + - 'local i = 1 ' + - 'if count > 0 then ' + - 'while messages[i] do ' + - 'redis.call("lpush", KEYS[2], messages[i]) ' + - 'i = i + 1 ' + - 'end ' + - 'redis.call("zremrangebyscore", KEYS[1], ' + - '"-inf", ARGV[1]) ' + - 'end ' + - 'return count', + code: ` + local messages = redis.call( + "zrangebyscore", KEYS[1], "-inf", ARGV[1]) + local count = table.getn(messages) + local message + local i = 1 + if count > 0 then + while messages[i] do + redis.call("lpush", KEYS[2], messages[i]) + i = i + 1 + end + redis.call("zremrangebyscore", KEYS[1], + "-inf", ARGV[1]) + end + return count + `, }, }; /** - * Serializes given data object into string + * Serializes a given data object into string * - * @param {any} data + * @param {unknown} data * @returns {string} */ - private readonly pack: (data: any) => string; + private readonly pack: (data: unknown) => string; /** - * Deserialize string data into object + * Deserialize string data into an object * * @param {string} data - * @returns {any} + * @returns {unknown} */ - private readonly unpack: (data: string) => any; + private readonly unpack: (data: string) => unknown; /** - * @constructor * @param {string} name * @param {IMQOptions} [options] * @param {IMQMode} [mode] @@ -269,30 +314,32 @@ export class RedisQueue extends EventEmitter ) { super(); - this.options = buildOptions( - DEFAULT_IMQ_OPTIONS, - options, - ); + this.options = buildOptions(DEFAULT_IMQ_OPTIONS, options); - /* tslint:disable */ this.pack = this.options.useGzip ? pack : JSON.stringify; this.unpack = this.options.useGzip ? unpack : JSON.parse; - /* tslint:enable */ this.redisKey = `${this.options.host}:${this.options.port}`; - this.verbose(`Initializing queue on ${ - this.options.host }:${ - this.options.port} with prefix ${ - this.options.prefix } and safeDelivery = ${ - this.options.safeDelivery }, and safeDeliveryTtl = ${ - this.options.safeDeliveryTtl }, and watcherCheckDelay = ${ - this.options.watcherCheckDelay }, and useGzip = ${ - this.options.useGzip }`); + for (const script of Object.keys(this.scripts)) { + this.scripts[script].checksum = sha1(this.scripts[script].code); + } + + this.verbose( + `Initializing queue on ${this.options.host}:${ + this.options.port + } with prefix ${this.options.prefix} and safeDelivery = ${ + this.options.safeDelivery + }, and safeDeliveryTtl = ${ + this.options.safeDeliveryTtl + }, and watcherCheckDelay = ${ + this.options.watcherCheckDelay + }, and useGzip = ${this.options.useGzip}`, + ); } private verbose(message: string): void { if (this.options.verbose) { - this.logger.info(`[IMQ-CORE][${ this.name }]: ${ message }`); + this.logger.info(`[IMQ-CORE][${this.name}]: ${message}`); } } @@ -301,25 +348,24 @@ export class RedisQueue extends EventEmitter * data read handler * * @param {string} channel - * @param {(data: JsonObject) => any} handler - * @return {Promise} + * @param {(data: JsonObject) => void} handler + * @returns {Promise} */ public async subscribe( channel: string, - handler: (data: JsonObject) => any, + handler: (data: JsonObject) => void, ): Promise { - // istanbul ignore next if (!channel) { throw new TypeError( - `${ channel }: No subscription channel name provided!`, + `${channel}: No subscription channel name provided!`, ); } - // istanbul ignore next if (this.subscriptionName && this.subscriptionName !== channel) { throw new TypeError( `Invalid channel name provided: expected "${ - this.subscriptionName}", but "${ channel }" given instead!`, + this.subscriptionName + }", but "${channel}" given instead!`, ); } else if (!this.subscriptionName) { this.subscriptionName = channel; @@ -329,26 +375,71 @@ export class RedisQueue extends EventEmitter const chan = await this.connect('subscription', this.options); await chan.subscribe(fcn); + this.attachSubscriptionHandler(chan, handler); + this.subscriptionHandlers.push(handler); + + this.verbose(`Subscribed to ${channel} channel`); + } + + /** + * Attaches a subscription message handler to a given channel + * connection + * + * @param {IRedisClient} chan + * @param {(data: JsonObject) => void} handler + */ + private attachSubscriptionHandler( + chan: IRedisClient, + handler: (data: JsonObject) => void, + ): void { + const fcn = `${this.options.prefix}:${this.subscriptionName}`; - // istanbul ignore next chan.on('message', (ch: string, message: string) => { if (ch === fcn && typeof handler === 'function') { - handler(JSON.parse(message) as unknown as JsonObject); + handler(JSON.parse(message) as JsonObject); } - this.verbose(`Received message from ${ - ch } channel, data: ${ - JSON.stringify(message) }`, + this.verbose( + `Received message from ${ch} channel, data: ${JSON.stringify( + message, + )}`, ); }); + } + + /** + * Restores the subscription state on a freshly created subscription + * connection. Used after a connection replacement on reconnection, + * otherwise the new connection would silently stay unsubscribed. + * + * @returns {Promise} + */ + private async restoreSubscription(): Promise { + const chan = this.subscription; + + if ( + !chan || + !this.subscriptionName || + !this.subscriptionHandlers.length + ) { + return; + } + + const fcn = `${this.options.prefix}:${this.subscriptionName}`; + + await chan.subscribe(fcn); + + for (const handler of this.subscriptionHandlers) { + this.attachSubscriptionHandler(chan, handler); + } - this.verbose(`Subscribed to ${ channel } channel`); + this.verbose(`Restored subscription to ${this.subscriptionName}`); } /** * Closes subscription channel * - * @return {Promise} + * @returns {Promise} */ public async unsubscribe(): Promise { if (this.subscription) { @@ -360,20 +451,24 @@ export class RedisQueue extends EventEmitter `${this.options.prefix}:${this.subscriptionName}`, ); - this.verbose(`Unsubscribed from ${ - this.subscriptionName } channel`); + this.verbose( + `Unsubscribed from ${this.subscriptionName} channel`, + ); } this.subscription.removeAllListeners(); - this.subscription.quit(); + await this.subscription.quit().catch(error => { + this.verbose(`Unsubscribe quit error: ${error}`); + }); this.subscription.disconnect(false); } catch (error) { - this.verbose(`Unsubscribe error: ${ error }`); + this.verbose(`Unsubscribe error: ${error}`); } } this.subscriptionName = undefined; this.subscription = undefined; + this.subscriptionHandlers = []; } /** @@ -395,14 +490,9 @@ export class RedisQueue extends EventEmitter const jsonData = JSON.stringify(data); const name = toName || this.name; - await this.writer.publish( - `${this.options.prefix}:${ name }`, - jsonData, - ); + await this.writer.publish(`${this.options.prefix}:${name}`, jsonData); - this.verbose(`Published message to ${ - name } channel, data: ${ - jsonData } + this.verbose(`Published message to ${name} channel, data: ${jsonData} `); } @@ -413,7 +503,7 @@ export class RedisQueue extends EventEmitter */ public async start(): Promise { if (!this.name) { - throw new TypeError(`${ this.name }: No queue name provided!`); + throw new TypeError(`${this.name}: No queue name provided!`); } if (this.initialized) { @@ -424,7 +514,6 @@ export class RedisQueue extends EventEmitter const connPromises = []; - // istanbul ignore next if (!this.reader && this.isWorker()) { this.verbose('Initializing reader...'); connPromises.push(this.connect('reader', this.options)); @@ -439,44 +528,129 @@ export class RedisQueue extends EventEmitter this.verbose('Connections initialized'); - if (!this.signalsInitialized) { - this.verbose('Setting up OS signal handlers...'); - // istanbul ignore next - const free = async () => { - let exitCode = 0; + RedisQueue.instances.add(this); - setTimeout(() => { - this.verbose(`Shutting down after ${ - IMQ_SHUTDOWN_TIMEOUT } timeout`, - ); - process.exit(exitCode); - }, IMQ_SHUTDOWN_TIMEOUT); - - try { - if (this.watchOwner) { - this.verbose('Freeing watcher lock...'); - await this.unlock(); - } - } catch (err) { - this.logger.error(err); - exitCode = 1; - } - }; - - process.on('SIGTERM', free); - process.on('SIGINT', free); - process.on('SIGABRT', free); + if (this.options.handleSignals !== false) { + RedisQueue.bindSignals(); + } - this.signalsInitialized = true; - this.verbose('OS signal handlers initialized!'); + if (!this.writerAcquired) { + RedisQueue.writerRefs[this.redisKey] = + (RedisQueue.writerRefs[this.redisKey] || 0) + 1; + this.writerAcquired = true; } await this.initWatcher(); + this.startWatcherCheck(); this.initialized = true; return this; } + /** + * Binds process-level signal handlers once per process. On shutdown + * signals, frees watcher locks held by any queue instance and exits. + */ + private static bindSignals(): void { + if (RedisQueue.signalsBound) { + return; + } + + RedisQueue.signalsBound = true; + + const free = (): void => { + void RedisQueue.freeAndExit(); + }; + + process.on('SIGTERM', free); + process.on('SIGINT', free); + process.on('SIGABRT', free); + } + + /** + * Frees watcher locks held by all started queue instances and exits + * the process, forcing exit after IMQ_SHUTDOWN_TIMEOUT at the latest. + * + * @returns {Promise} + */ + private static async freeAndExit(): Promise { + let exitCode = 0; + const timer = setTimeout(() => { + process.exit(exitCode || 1); + }, IMQ_SHUTDOWN_TIMEOUT); + + await Promise.all( + [...RedisQueue.instances] + .filter(queue => queue.watchOwner) + .map(queue => + queue.unlock().catch(err => { + queue.logger.error(err); + exitCode = 1; + }), + ), + ); + + clearTimeout(timer); + process.exit(exitCode); + } + + /** + * Starts a periodic check ensuring a watcher connection exists across + * the queue network, re-electing an owner when the previous one died. + * Also serves as a polling fallback moving due to delayed messages when + * keyspace notifications are unavailable. + */ + private startWatcherCheck(): void { + if (this.watcherCheckInterval || !this.options.watcherCheckDelay) { + return; + } + + this.watcherCheckInterval = setInterval( + this.runWatcherCheck.bind(this), + this.options.watcherCheckDelay, + ); + this.watcherCheckInterval.unref(); + } + + /** + * A single watcher-existence check tick: re-elects a watcher owner when + * none exists and moves due to delayed messages as a keyspace-notification + * fallback. Errors are contained so the interval never crashes. + * + * @returns {Promise} + */ + private async runWatcherCheck(): Promise { + if (this.watcherCheckBusy || this.destroyed || !this.writer) { + return; + } + + this.watcherCheckBusy = true; + + try { + if (!(await this.watcherCount())) { + await this.initWatcher(); + } + + if (this.isWorker()) { + await this.processDelayed(this.key); + } + } catch (err) { + this.verbose(`Watcher check error: ${err}`); + } finally { + this.watcherCheckBusy = false; + } + } + + /** + * Stops the periodic watcher check + */ + private stopWatcherCheck(): void { + if (this.watcherCheckInterval) { + clearInterval(this.watcherCheckInterval); + this.watcherCheckInterval = undefined; + } + } + /** * Sends a given message to a given queue (by name) * @@ -496,7 +670,6 @@ export class RedisQueue extends EventEmitter throw new TypeError('IMQ: Unable to publish in WORKER only mode!'); } - // istanbul ignore next if (!this.writer) { await this.start(); } @@ -505,51 +678,70 @@ export class RedisQueue extends EventEmitter throw new TypeError('IMQ: unable to initialize queue!'); } - const id = uuid(); + const id = randomUUID(); const data: IMessage = { id, message, from: this.name }; const key = `${this.options.prefix}:${toQueue}`; const packet = this.pack(data); - const cb = (error: any, op: string) => { - // istanbul ignore next + const onWriteError = (error: unknown, op: string): void => { if (error) { - this.verbose(`Writer ${ op } error: ${ error }`); + this.verbose(`Writer ${op} error: ${error}`); if (errorHandler) { - errorHandler(error as unknown as Error); + errorHandler( + error instanceof Error + ? error + : new Error(String(error)), + ); } } }; if (delay) { - this.writer.zadd(`${key}:delayed`, Date.now() + delay, packet, - (err: any) => { - // istanbul ignore next + this.writer.zadd( + `${key}:delayed`, + Date.now() + delay, + packet, + (err?: Error | null) => { if (err) { - cb(err, 'ZADD'); + onWriteError(err, 'ZADD'); return; } - this.writer.set(`${key}:${id}:ttl`, '', 'PX', delay, 'NX', - (err: any) => { - // istanbul ignore next - if (err) { - cb(err, 'SET'); - - return; - } - }, - ).catch((err: any) => cb(err, 'SET')); - }); + this.writer + .set( + `${key}:${id}:ttl`, + '', + 'PX', + delay, + 'NX', + (err?: Error | null) => { + if (err) { + onWriteError(err, 'SET'); + + return; + } + }, + ) + .catch((err: unknown) => onWriteError(err, 'SET')); + }, + ); } else { - this.writer.lpush(key, packet, (err: any) => { - // istanbul ignore next - if (err) { - cb(err, 'LPUSH'); + const result = this.writer.lpush( + key, + packet, + (err?: Error | null) => { + if (err) { + onWriteError(err, 'LPUSH'); + } + }, + ); - return; - } - }); + // guard against unhandled rejections from promise-returning + // client implementations in fire-and-forget mode + if (result && typeof result.catch === 'function') { + result.catch((err: unknown) => onWriteError(err, 'LPUSH')); + } } return id; @@ -566,7 +758,7 @@ export class RedisQueue extends EventEmitter if (this.reader) { this.verbose('Destroying reader...'); - this.destroyChannel('reader', this); + this.destroyChannel('reader'); delete this.reader; } @@ -579,19 +771,37 @@ export class RedisQueue extends EventEmitter } /** - * Gracefully destroys this queue + * Gracefully destroys this queue handle. Does not remove queue data + * from redis unless clearData is explicitly set to true, so that + * destroying one handle (e.g., on scale-down) never wipes messages + * still pending for other producers/consumers. * + * @param {boolean} [clearData] - when true, also clears queue data * @returns {Promise} */ @profile() - public async destroy(): Promise { + public async destroy(clearData: boolean = false): Promise { this.verbose('Destroying queue...'); this.destroyed = true; + RedisQueue.instances.delete(this); this.removeAllListeners(); this.cleanSafeCheckInterval(); - this.destroyWatcher(); + this.stopWatcherCheck(); + + if (this.watchOwner) { + await this.unlock().catch(err => + this.verbose(`Unlock error: ${err}`), + ); + this.destroyWatcher(); + this.watchOwner = false; + } + await this.stop(); - await this.clear(); + + if (clearData) { + await this.clear(); + } + this.destroyWriter(); await this.unsubscribe(); this.verbose('Queue destroyed!'); @@ -613,16 +823,16 @@ export class RedisQueue extends EventEmitter await Promise.all([ this.writer.del(this.key), - this.writer.del(`${ this.key }:delayed`), + this.writer.del(`${this.key}:delayed`), ]); this.verbose('Expired queue keys cleared!'); } catch (err) { - // istanbul ignore next if (this.initialized) { this.logger.error( - `${ context.name }: error clearing the redis queue host ${ - this.redisKey } on writer, pid ${ process.pid }:`, + `${this.name}: error clearing the redis queue host ${ + this.redisKey + } on writer, pid ${process.pid}:`, err, ); } @@ -648,7 +858,7 @@ export class RedisQueue extends EventEmitter /** * Returns true if publisher mode is enabled on this queue, false otherwise. * - * @return {boolean} + * @returns {boolean} */ public isPublisher(): boolean { return this.mode === IMQMode.BOTH || this.mode === IMQMode.PUBLISHER; @@ -657,13 +867,25 @@ export class RedisQueue extends EventEmitter /** * Returns true if worker mode is enabled on this queue, false otherwise. * - * @return {boolean} + * @returns {boolean} */ public isWorker(): boolean { return this.mode === IMQMode.BOTH || this.mode === IMQMode.WORKER; } - // noinspection JSMethodCanBeStatic + /** + * Returns false only when this queue is known to be unable to accept + * writes right now — i.e., it has a writer connection currently + * in a non-ready (reconnecting/closed) state. A queue that has not yet + * connected is considered available, since a sending lazily connects it. + * Used for health-aware routing in the clustered queue. + * + * @returns {boolean} + */ + public get available(): boolean { + return !this.writer || this.writer.status === 'ready'; + } + /** * Writer connection associated with this queue instance * @@ -673,13 +895,11 @@ export class RedisQueue extends EventEmitter return RedisQueue.writers[this.redisKey]; } - // noinspection JSUnusedLocalSymbols /** * Writer connection setter. * * @param {IRedisClient} conn */ - // noinspection JSUnusedLocalSymbols,JSUnusedLocalSymbols private set writer(conn: IRedisClient) { RedisQueue.writers[this.redisKey] = conn; } @@ -693,41 +913,83 @@ export class RedisQueue extends EventEmitter return RedisQueue.watchers[this.redisKey]; } - // noinspection JSUnusedLocalSymbols /** * Watcher setter sets the watcher connection property for this * queue instance * * @param {IRedisClient} conn */ - // noinspection JSUnusedLocalSymbols private set watcher(conn: IRedisClient) { RedisQueue.watchers[this.redisKey] = conn; } + /** + * Returns the connection currently bound to the given channel, if any. + * + * @param {RedisConnectionChannel} channel + * @returns {IRedisClient | undefined} + */ + private connectionOf( + channel: RedisConnectionChannel, + ): IRedisClient | undefined { + switch (channel) { + case 'reader': + return this.reader; + case 'writer': + return this.writer; + case 'watcher': + return this.watcher; + case 'subscription': + return this.subscription; + } + } + + /** + * Binds the given connection to the given channel. + * + * @param {RedisConnectionChannel} channel + * @param {IRedisClient} conn + */ + private bindConnection( + channel: RedisConnectionChannel, + conn: IRedisClient, + ): void { + switch (channel) { + case 'reader': + this.reader = conn; + break; + case 'writer': + this.writer = conn; + break; + case 'watcher': + this.watcher = conn; + break; + case 'subscription': + this.subscription = conn; + break; + } + } + /** * Logger instance associated with the current queue instance * @type {ILogger} */ private get logger(): ILogger { - // istanbul ignore next return this.options.logger || console; } /** * Return a lock key for watcher connection * - * @access private * @returns {string} */ private get lockKey(): string { - return `${ this.options.prefix }:watch:lock`; + return `${this.options.prefix}:watch:lock`; } /** * Returns current queue key * - * @access private * @returns {string} */ private get key(): string { @@ -736,14 +998,12 @@ export class RedisQueue extends EventEmitter /** * Destroys watcher channel - * - * @access private */ @profile() private destroyWatcher(): void { if (this.watcher) { this.verbose('Destroying watcher...'); - this.destroyChannel('watcher', this); + this.destroyChannel('watcher'); delete RedisQueue.watchers[this.redisKey]; this.verbose('Watcher destroyed!'); } @@ -751,14 +1011,28 @@ export class RedisQueue extends EventEmitter /** * Destroys writer channel - * - * @access private */ @profile() - private destroyWriter(): void { + private destroyWriter(release: boolean = true): void { + if (release && this.writerAcquired) { + this.writerAcquired = false; + RedisQueue.writerRefs[this.redisKey] = Math.max( + 0, + (RedisQueue.writerRefs[this.redisKey] || 1) - 1, + ); + + if (RedisQueue.writerRefs[this.redisKey] > 0) { + // the shared writer connection is still used by other + // queue instances within this process + this.verbose('Writer is still in use, skipping destroy...'); + + return; + } + } + if (this.writer) { this.verbose('Destroying writer...'); - this.destroyChannel('writer', this); + this.destroyChannel('writer'); delete RedisQueue.writers[this.redisKey]; this.verbose('Writer destroyed!'); } @@ -766,66 +1040,82 @@ export class RedisQueue extends EventEmitter /** * Destroys any channel - * - * @access private */ @profile() - private destroyChannel( - channel: RedisConnectionChannel, - context: RedisQueue = this, - ): void { - const client = context[channel]; + private destroyChannel(channel: RedisConnectionChannel): void { + const client = this.connectionOf(channel); - if (client) { - try { - client.removeAllListeners(); - client.quit().then(() => { + if (!client) { + return; + } + + try { + client.removeAllListeners(); + + let disconnected = false; + const forceDisconnect = (): void => { + if (disconnected) { + return; + } + + disconnected = true; + + try { client.disconnect(false); - }).catch(e => { - this.verbose(`Error quitting ${ channel }: ${ e }`); - }); - } catch (error) { - this.verbose(`Error destroying ${ channel }: ${ error }`); - } + } catch (error) { + this.verbose(`Error disconnecting ${channel}: ${error}`); + } + }; + + // graceful quit for idle connections, but a reader blocked on an + // infinite BRPOP/BLMOVE never lets QUIT through, so guarantee a + // forced disconnect after a short grace period to avoid leaking + // the socket (which would keep the process alive) + client.quit().then(forceDisconnect, forceDisconnect); + + const timer = setTimeout( + forceDisconnect, + IMQ_CONNECTION_QUIT_TIMEOUT, + ); + + // the grace timer itself must not keep the process alive; while + // the leaked socket keeps the loop running the timer still fires + timer.unref(); + } catch (error) { + this.verbose(`Error destroying ${channel}: ${error}`); } } /** * Establishes a given connection channel by its name * - * @access private * @param {RedisConnectionChannel} channel * @param {IMQOptions} options - * @param {any} context * @returns {Promise} */ private async connect( channel: RedisConnectionChannel, options: IMQOptions, - context: RedisQueue = this, ): Promise { - this.verbose(`Connecting to ${ channel } channel...`); + this.verbose(`Connecting to ${channel} channel...`); + + const existing = this.connectionOf(channel); - // istanbul ignore next - if (context[channel]) { - return context[channel]; + if (existing) { + return existing; } - const redis = new Redis({ - // istanbul ignore next + const redis: IRedisClient = new Redis({ port: options.port || 6379, - // istanbul ignore next host: options.host || 'localhost', - // istanbul ignore next username: options.username, - // istanbul ignore next password: options.password, connectionName: this.getChannelName( - context.name + '', + this.name, options.prefix || '', channel, ), - retryStrategy: this.retryStrategy(), + retryStrategy: noRetryStrategy, autoResubscribe: true, enableOfflineQueue: true, autoResendUnfulfilledCommands: true, @@ -835,8 +1125,8 @@ export class RedisQueue extends EventEmitter lazyConnect: true, }); - context[channel] = redis; - context[channel].__imq = true; + this.bindConnection(channel, redis); + redis.__imq = true; for (const event of [ 'wait', @@ -845,42 +1135,39 @@ export class RedisQueue extends EventEmitter 'connect', 'close', ]) { - redis.on(event, () => { - context.verbose(`Redis Event fired: ${ event }`); - }); + redis.on(event, () => this.verbose(`Redis Event fired: ${event}`)); } redis.setMaxListeners(IMQ_REDIS_MAX_LISTENERS_LIMIT); - redis.on('error', this.onErrorHandler(context, channel)); - redis.on('end', this.onCloseHandler(context, channel)); + redis.on('error', this.onErrorHandler(channel)); + redis.on('end', this.onCloseHandler(channel)); await redis.connect(); this.logger.info( '%s: %s channel connected, host %s, pid %s', - context.name, channel, this.redisKey, process.pid, + this.name, + channel, + this.redisKey, + process.pid, ); switch (channel) { - case 'reader': this.read(); break; - case 'writer': await this.processDelayed(this.key); break; - case 'watcher': await this.initWatcher(); break; + case 'reader': + this.read(); + break; + case 'writer': + await this.processDelayed(this.key); + break; + case 'watcher': + await this.initWatcher(); + break; + case 'subscription': + await this.restoreSubscription(); + break; } - return context[channel]; - } - - // istanbul ignore next - /** - * Builds and returns redis reconnection strategy - * - * @returns {() => (number | void | null)} - * @private - */ - private retryStrategy(): () => null { - return () => { - return null; - }; + return redis; } /** @@ -891,184 +1178,185 @@ export class RedisQueue extends EventEmitter * @private */ private scheduleReconnect(channel: RedisConnectionChannel): void { - if (this.destroyed) { - return; - } - - if (this.reconnecting[channel]) { + if (this.destroyed || this.reconnecting[channel]) { return; } const attempts = (this.reconnectAttempts[channel] || 0) + 1; - const delay = Math.min(30000, 1000 * Math.pow(2, attempts - 1)); + const delayMs = Math.min( + RECONNECT_MAX_DELAY, + RECONNECT_BASE_DELAY * 2 ** (attempts - 1), + ); this.reconnecting[channel] = true; this.reconnectAttempts[channel] = attempts; - this.verbose(`Scheduling ${ channel } reconnect in ${ - delay } ms (attempt ${ attempts })`); + this.verbose( + `Scheduling ${channel} reconnect in ${delayMs} ms ` + + `(attempt ${attempts})`, + ); if (this.reconnectTimers[channel]) { - clearTimeout(this.reconnectTimers[channel] as any); + clearTimeout(this.reconnectTimers[channel]); } - this.reconnectTimers[channel] = setTimeout(async () => { - if (this.destroyed) { - this.reconnecting[channel] = false; - - return; - } + this.reconnectTimers[channel] = setTimeout( + this.reconnectNow.bind(this, channel), + delayMs, + ); + } - try { - switch (channel) { - case 'watcher': - this.destroyWatcher(); - break; - case 'writer': - this.destroyWriter(); - break; - case 'reader': - this.destroyChannel(channel, this); - this.reader = undefined; + /** + * Performs a single reconnection attempt for the given channel, + * rescheduling itself on failure. Errors are handled internally, so the + * scheduled timer never produces an unhandled rejection. + * + * @param {RedisConnectionChannel} channel + * @returns {Promise} + */ + private async reconnectNow(channel: RedisConnectionChannel): Promise { + if (this.destroyed) { + this.reconnecting[channel] = false; - break; - case 'subscription': - this.destroyChannel(channel, this); - this.subscription = undefined; + return; + } - break; - } + try { + switch (channel) { + case 'watcher': + this.destroyWatcher(); + break; + case 'writer': + // replace the broken shared connection without + // releasing this instance's reference to it + this.destroyWriter(false); + break; + case 'reader': + this.destroyChannel(channel); + this.reader = undefined; + break; + case 'subscription': + this.destroyChannel(channel); + this.subscription = undefined; + break; + } - await this.connect(channel, this.options); - this.reconnectAttempts[channel] = 0; - this.reconnecting[channel] = false; + await this.connect(channel, this.options); + this.reconnectAttempts[channel] = 0; + this.reconnecting[channel] = false; - if (this.reconnectTimers[channel]) { - clearTimeout(this.reconnectTimers[channel] as any); - this.reconnectTimers[channel] = undefined; - } - - this.verbose(`Reconnected ${ channel } channel`); - } catch (err) { - this.reconnecting[channel] = false; - this.verbose(`Reconnect ${ channel } failed: ${ err }`); - this.scheduleReconnect(channel); + if (this.reconnectTimers[channel]) { + clearTimeout(this.reconnectTimers[channel]); + this.reconnectTimers[channel] = undefined; } - }, delay); + + this.verbose(`Reconnected ${channel} channel`); + } catch (err) { + this.reconnecting[channel] = false; + this.verbose(`Reconnect ${channel} failed: ${err}`); + this.scheduleReconnect(channel); + } } - // noinspection JSMethodCanBeStatic /** * Generates channel name * * @param {string} contextName * @param {string} prefix * @param {RedisConnectionChannel} name - * @return {string} + * @returns {string} */ private getChannelName( contextName: string, prefix: string, name: RedisConnectionChannel, ): string { - const uniqueSuffix = `pid:${ process.pid }:host:${ os.hostname() }`; + const uniqueSuffix = `pid:${process.pid}:host:${hostname()}`; - return`${ prefix }:${ contextName }:${ name }:${ uniqueSuffix }`; + return `${prefix}:${contextName}:${name}:${uniqueSuffix}`; } /** * Builds and returns connection error handler * - * @access private - * @param {RedisQueue} context * @param {RedisConnectionChannel} channel - * @return {(err: Error) => void} + * @returns {(err: Error) => void} */ private onErrorHandler( - context: RedisQueue, channel: RedisConnectionChannel, ): (error: Error) => void { - // istanbul ignore next - return ((error: Error & { code: string }) => { - this.verbose(`Redis Error: ${ error }`); + return (error: Error & { code?: string }) => { + this.verbose(`Redis Error: ${error}`); if (this.destroyed) { return; } this.logger.error( - `${context.name}: error connecting redis host ${ - this.redisKey} on ${ - channel}, pid ${process.pid}:`, + `${this.name}: error connecting redis host ${ + this.redisKey + } on ${channel}, pid ${process.pid}:`, error, ); if ( error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || - context[channel]?.status !== 'ready' + this.connectionOf(channel)?.status !== 'ready' ) { this.scheduleReconnect(channel); } - }); + }; } /** * Builds and returns redis connection close handler * - * @access private - * @param {RedisQueue} context * @param {RedisConnectionChannel} channel - * @return {(...args: any[]) => any} + * @returns {() => void} */ - private onCloseHandler( - context: RedisQueue, - channel: RedisConnectionChannel, - ): (...args: any[]) => any { - this.verbose(`Redis ${ channel } is closing...`); + private onCloseHandler(channel: RedisConnectionChannel): () => void { + this.verbose(`Redis ${channel} is closing...`); - // istanbul ignore next - return (() => { + return () => { this.initialized = false; this.logger.warn( '%s: redis connection %s closed on host %s, pid %s!', - context.name, channel, this.redisKey, process.pid, + this.name, + channel, + this.redisKey, + process.pid, ); if (!this.destroyed) { this.scheduleReconnect(channel); } - }); + }; } /** * Processes given redis-queue message * - * @access private - * @param {[any, any]} msg + * @param {[string, string]} msg * @returns {RedisQueue} */ - private process(msg: [any, any]): RedisQueue { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + private process(msg: [string, string]): RedisQueue { const [queue, data] = msg; - // istanbul ignore next if (!queue || queue !== this.key) { return this; } try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-argument - const { id, message, from } = this.unpack(data); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const { id, message, from } = this.unpack(data) as IMessage; + this.emit('message', message, id, from); } catch (err) { - // istanbul ignore next this.emitError( 'OnMessage', 'process error - message is invalid', - err as unknown as Error, + err, ); } @@ -1078,19 +1366,17 @@ export class RedisQueue extends EventEmitter /** * Returns the number of established watcher connections on redis * - * @access private * @returns {Promise} */ - // istanbul ignore next private async watcherCount(): Promise { if (!this.writer) { return 0; } const rx = new RegExp( - `\\bname=${this.options.prefix}:[\\S]+?:watcher:`, + `\\bname=${escapeRegExp(this.options.prefix || '')}:\\S+?:watcher:`, ); - const list = await this.writer.client('LIST'); + const list = (await this.writer.client('LIST')) as string; if (!list || !list.split) { return 0; @@ -1102,33 +1388,52 @@ export class RedisQueue extends EventEmitter /** * Processes delayed a message by its given redis key * - * @access private * @param {string} key * @returns {Promise} */ private async processDelayed(key: string): Promise { try { - if (this.scripts.moveDelayed.checksum) { + if (!this.scripts.moveDelayed.checksum || !this.writer) { + return; + } + + try { await this.writer.evalsha( this.scripts.moveDelayed.checksum, - 2, `${ key }:delayed`, key, Date.now(), + 2, + `${key}:delayed`, + key, + Date.now(), ); + } catch (err) { + // the script may not be cached on the redis host (fresh + // host, restart, non-owner instance) - fall back to EVAL, + // which caches it as a side effect + if (err instanceof Error && /NOSCRIPT/.test(err.message)) { + await this.writer.eval( + this.scripts.moveDelayed.code, + 2, + `${key}:delayed`, + key, + Date.now(), + ); + } else { + throw err; + } } } catch (err) { this.emitError( 'OnProcessDelayed', 'error processing delayed queue', - err as unknown as Error, + err, ); } } - // istanbul ignore next /** * Watch routine * - * @access private - * @return {Promise} + * @returns {Promise} */ private async processWatch(): Promise { const now = Date.now(); @@ -1136,17 +1441,15 @@ export class RedisQueue extends EventEmitter while (true) { try { - const data = await this.writer.scan( + const [next, keys] = await this.writer.scan( cursor, 'MATCH', - `${ this.options.prefix }:*:worker:*`, + `${this.options.prefix}:*:worker:*`, 'COUNT', - '1000', + SCAN_COUNT, ); - cursor = data.shift() as string; - - const keys = data.shift() as string[] || []; + cursor = next; await this.processKeys(keys, now); @@ -1157,7 +1460,7 @@ export class RedisQueue extends EventEmitter this.emitError( 'OnSafeDelivery', 'safe queue message delivery problem', - err as unknown as Error, + err, ); this.cleanSafeCheckInterval(); @@ -1166,42 +1469,49 @@ export class RedisQueue extends EventEmitter } } - // istanbul ignore next /** * Process given keys from a message queue * - * @access private * @param {string[]} keys * @param {number} now - * @return {Promise} + * @returns {Promise} */ private async processKeys(keys: string[], now: number): Promise { if (!keys.length) { return; } - this.verbose(`Watching ${ keys.length } keys: ${ - keys.map(key => `"${ key }"`).join(', ') - }`); + this.verbose( + `Watching ${keys.length} keys: ${keys + .map(key => `"${key}"`) + .join(', ')}`, + ); for (const key of keys) { const kp: string[] = key.split(':'); - if (Number(kp.pop()) < now) { + // the last key segment is the worker's lease deadline: only + // re-queue messages of workers whose lease has expired (the + // worker died mid-processing); fresh leases belong to live + // workers and must not be touched + if (Number(kp.pop()) >= now) { continue; } - await this.writer.rpoplpush(key, `${ kp.shift() }:${ kp.shift() }`); + await this.writer.lmove( + key, + `${kp.shift()}:${kp.shift()}`, + 'RIGHT', + 'LEFT', + ); } } - // istanbul ignore next /** * Watch message processor * - * @access private * @param {...any[]} args - * @return {Promise} + * @returns {Promise} */ private async onWatchMessage(...args: any[]): Promise { try { @@ -1215,23 +1525,16 @@ export class RedisQueue extends EventEmitter await this.processDelayed(key.join(':')); } catch (err) { - this.emitError( - 'OnWatch', - 'watch error', - err as unknown as Error, - ); + this.emitError('OnWatch', 'watch error', err); } } - // istanbul ignore next /** * Clears safe check interval - * - * @access private */ private cleanSafeCheckInterval(): void { if (this.safeCheckInterval) { - clearInterval(this.safeCheckInterval as number); + clearInterval(this.safeCheckInterval); delete this.safeCheckInterval; } } @@ -1239,64 +1542,41 @@ export class RedisQueue extends EventEmitter /** * Setups watch a process on delayed messages * - * @access private * @returns {RedisQueue} */ - // istanbul ignore next private watch(): RedisQueue { if (!this.writer || !this.watcher || this.watcher.__ready__) { return this; } try { - this.writer.config( - 'SET', - 'notify-keyspace-events', - 'Ex', - ).catch(err => { - this.emitError( - 'OnConfig', - 'events config error', - err as unknown as Error, + this.writer + .config('SET', 'notify-keyspace-events', 'Ex') + .catch((err: unknown) => + this.emitError('OnConfig', 'events config error', err), ); - }); } catch (err) { - this.emitError( - 'OnConfig', - 'events config error', - err as unknown as Error, - ); + this.emitError('OnConfig', 'events config error', err); } - this.watcher.on( - 'pmessage', - this.onWatchMessage.bind(this) as unknown as () => void, - ); - this.watcher.psubscribe( - '__keyevent@0__:expired', - `${ this.options.prefix }:delayed:*`, - ).catch(err => { - this.verbose(`Error subscribing to watcher channel: ${ err }`); - }); + this.watcher.on('pmessage', this.onWatchMessage.bind(this)); + this.watcher + .psubscribe( + '__keyevent@0__:expired', + `${this.options.prefix}:delayed:*`, + ) + .catch((err: unknown) => + this.verbose(`Error subscribing to watcher channel: ${err}`), + ); // watch for expired unhandled safe queues - if (!this.safeCheckInterval) { - if (this.options.safeDeliveryTtl != null) { - this.safeCheckInterval = setInterval( - (async (): Promise => { - if (!this.writer) { - this.cleanSafeCheckInterval(); - - return ; - } - - if (this.options.safeDelivery) { - await this.processWatch(); - } - - await this.processCleanup(); - }) as unknown as () => void, this.options.safeDeliveryTtl); - } + if (!this.safeCheckInterval && this.options.safeDeliveryTtl != null) { + this.safeCheckInterval = setInterval( + this.runSafeCheck.bind(this), + this.options.safeDeliveryTtl, + ); + // maintenance timer must not keep the process alive on its own + this.safeCheckInterval.unref(); } this.watcher.__ready__ = true; @@ -1304,10 +1584,29 @@ export class RedisQueue extends EventEmitter return this; } + /** + * A single safe-delivery maintenance tick: recovers messages from dead + * workers (when safe delivery is on) and prunes orphaned keys. + * + * @returns {Promise} + */ + private async runSafeCheck(): Promise { + if (!this.writer) { + this.cleanSafeCheckInterval(); + + return; + } + + if (this.options.safeDelivery) { + await this.processWatch(); + } + + await this.processCleanup(); + } + /** * Cleans up orphaned keys from redis * - * @access private * @returns {Promise} */ private async processCleanup(): Promise { @@ -1319,54 +1618,70 @@ export class RedisQueue extends EventEmitter } const filter: RegExp = new RegExp( - this.options.prefix + ':' + - (this.options.cleanupFilter || '*').replace(/\*/g, '.*'), + escapeRegExp(this.options.prefix || '') + + ':' + + escapeRegExp(this.options.cleanupFilter || '*').replace( + /\\\*/g, + '.*', + ), 'i', ); - this.verbose(`Cleaning up keys matching ${ filter }`); + this.verbose(`Cleaning up keys matching ${filter}`); - const clients: string = (await this.writer.client( - 'LIST', - ) as string).toString() || ''; + const clients: string = + ((await this.writer.client('LIST')) as string).toString() || ''; const connectedKeys = (clients.match(RX_CLIENT_NAME) || []) - .filter((name: string) => - RX_CLIENT_TEST.test(name) && filter.test(name), + .filter( + (name: string) => + RX_CLIENT_TEST.test(name) && filter.test(name), ) - .map((name: string) => name - .replace(/^name=/, '') - .replace(RX_CLIENT_CLEAN, ''), + .map((name: string) => + name.replace(/^name=/, '').replace(RX_CLIENT_CLEAN, ''), ) - .filter((name: string, i: number, a: string[]) => - a.indexOf(name) === i, + .filter( + (name: string, i: number, a: string[]) => + a.indexOf(name) === i, ); + // clients seen connected during the previous sweep get one + // sweep of grace: a client merely reconnecting (the + // backoff can reach tens of seconds) must not have any keys + // deleted from under it + const knownKeys = connectedKeys.concat( + this.lastConnectedKeys.filter( + key => !connectedKeys.includes(key), + ), + ); + + this.lastConnectedKeys = connectedKeys; + const keysToRemove: string[] = []; let cursor = '0'; - this.verbose(`Found connected keys: ${ - connectedKeys.map(k => `"${ k }"`).join(', ') - }`); + this.verbose( + `Found connected keys: ${knownKeys + .map(k => `"${k}"`) + .join(', ')}`, + ); while (true) { - const data = await this.writer.scan( + const [next, keys] = await this.writer.scan( cursor, 'MATCH', - `${ - this.options.prefix}:${ + `${this.options.prefix}:${ this.options.cleanupFilter || '*' }`, 'COUNT', - '1000', + SCAN_COUNT, ); - cursor = data.shift() as string; - - const keys = data.shift() as string[] || []; + cursor = next; keysToRemove.push( ...keys.filter( - key => key !== this.lockKey && - connectedKeys.every( + key => + key !== this.lockKey && + knownKeys.every( connectedKey => key.indexOf(connectedKey) === -1, ), @@ -1374,16 +1689,18 @@ export class RedisQueue extends EventEmitter ); if (cursor === '0') { - break ; + break; } } if (keysToRemove.length) { await this.writer.del(...keysToRemove); - this.verbose(`Keys ${ - keysToRemove.map(k => `"${ k }"`).join(', ') - } were successfully removed!`); + this.verbose( + `Keys ${keysToRemove + .map(k => `"${k}"`) + .join(', ')} were successfully removed!`, + ); } } catch (err) { this.logger.warn('Clean-up error occurred:', err); @@ -1392,7 +1709,6 @@ export class RedisQueue extends EventEmitter return this; } - // noinspection JSUnusedLocalSymbols /** * Unreliable but fast way of message handling by the queue */ @@ -1412,75 +1728,83 @@ export class RedisQueue extends EventEmitter this.process(msg); } } catch (err) { - // istanbul ignore next - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - if (err.message.match(/Stream connection ended/)) { + // a closed/ended reader connection means the queue is + // stopping, reconnecting, or being destroyed - end the + // loop quietly; reconnection (if any) is driven by the + // connection close handler, not by this loop + if ( + this.destroyed || + !this.reader || + (err instanceof Error && + /Stream connection ended|Connection is closed/i.test( + err.message, + )) + ) { break; } - // istanbul ignore next - // noinspection ExceptionCaughtLocallyJS throw err; } } } catch (err) { - // istanbul ignore next - this.emitError( - 'OnReadUnsafe', - 'unsafe reader failed', - err as unknown as Error, - ); + this.emitError('OnReadUnsafe', 'unsafe reader failed', err); } } - // noinspection JSUnusedLocalSymbols /** - * Reliable but slow method of message handling by message queue + * Reliable but slow method of message handling by message queue. + * + * Uses a bounded blocking pop so the lease deadline embedded into the + * worker key never goes stale: with an infinite block a message + * arriving long after the pop started would be born with an already + * expired lease and be immediately re-queued by the watcher. */ private async readSafe(): Promise { - try { - const key = this.key; - - while (true) { - const expire: number = Date.now() + - Number(this.options.safeDeliveryTtl); - const workerKey = `${key}:worker:${uuid()}:${expire}`; + const key = this.key; + // blocking timeout in seconds, at most half of the lease ttl, so + // a claimed message always has at least half the ttl remaining + const timeout = Math.max( + 0.1, + Number(this.options.safeDeliveryTtl) / 2000, + ); - if (!this.reader || !this.writer) { - break; - } + while (true) { + if (!this.reader || !this.writer || this.destroyed) { + break; + } - try { - await this.reader.brpoplpush(this.key, workerKey, 0); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (_) { - // istanbul ignore next - break; - } + const expire: number = + Date.now() + Number(this.options.safeDeliveryTtl); + const workerKey = `${key}:worker:${randomUUID()}:${expire}`; + let msg: string | null; - const msgArr: any = await this.writer.lrange( - workerKey, -1, 1, + try { + msg = await this.reader.blmove( + this.key, + workerKey, + 'RIGHT', + 'LEFT', + timeout, ); + } catch { + // reader connection ended (stop/reconnect) + break; + } - if (!msgArr || msgArr?.length !== 1) { - // noinspection ExceptionCaughtLocallyJS - throw new Error('Wrong messages count'); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const [msg] = msgArr as any[]; + if (msg === null || msg === undefined) { + // blocking pop timed out: regenerate the lease and retry + continue; + } + try { this.process([key, msg]); - this.writer.del(workerKey).catch(e => - this.logger.warn('OnReadSafe: del error', e)); + this.writer + .del(workerKey) + .catch(e => this.logger.warn('OnReadSafe: del error', e)); + } catch (err) { + // a single message failure must never kill the read loop + this.emitError('OnReadSafe', 'safe reader failed', err); } - } catch (err) { - // istanbul ignore next - this.emitError( - 'OnReadSafe', - 'safe reader failed', - err as unknown as Error, - ); } } @@ -1490,22 +1814,21 @@ export class RedisQueue extends EventEmitter * @returns {RedisQueue} */ private read(): RedisQueue { - // istanbul ignore next if (!this.reader) { this.logger.error( `${this.name}: reader connection is not initialized, pid ${ - process.pid} on redis host ${this.redisKey}!`, + process.pid + } on redis host ${this.redisKey}!`, ); return this; } - const readMethod = this.options.safeDelivery - ? 'readSafe' - : 'readUnsafe'; + const runReader = this.options.safeDelivery + ? this.readSafe + : this.readUnsafe; - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - process.nextTick(this[readMethod].bind(this)); + process.nextTick(runReader.bind(this)); return this; } @@ -1513,7 +1836,6 @@ export class RedisQueue extends EventEmitter /** * Checks if the watcher connection is locked * - * @access private * @returns {Promise} */ private async isLocked(): Promise { @@ -1527,7 +1849,6 @@ export class RedisQueue extends EventEmitter /** * Locks watcher connection * - * @access private * @returns {Promise} */ private async lock(): Promise { @@ -1541,7 +1862,6 @@ export class RedisQueue extends EventEmitter /** * Unlocks watcher connection * - * @access private * @returns {Promise} */ private async unlock(): Promise { @@ -1552,25 +1872,34 @@ export class RedisQueue extends EventEmitter return false; } - // istanbul ignore next /** * Emits error * - * @access private * @param {string} eventName * @param {string} message - * @param {Error} err + * @param {unknown} err */ - private emitError(eventName: string, message: string, err: Error): void { - this.emit('error', err, eventName); + private emitError(eventName: string, message: string, err: unknown): void { + const error = err instanceof Error ? err : new Error(String(err)); + + // emitting 'error' with no listeners attached would throw and + // crash the process from a background routine - always log, but + // only emit when someone actually listens + if (this.listenerCount('error') > 0) { + this.emit('error', error, eventName); + } + this.logger.error( - `${this.name}: ${ message }, pid ${ - process.pid } on redis host ${ this.redisKey }:`, + `${this.name}: ${message}, pid ${ + process.pid + } on redis host ${this.redisKey}:`, err, ); - this.verbose(`Error in event ${ - eventName }: ${ message }, pid ${ - process.pid } on redis host ${ this.redisKey }: ${ err }`); + this.verbose( + `Error in event ${eventName}: ${message}, pid ${ + process.pid + } on redis host ${this.redisKey}: ${err}`, + ); } /** @@ -1578,7 +1907,6 @@ export class RedisQueue extends EventEmitter * * @returns {Promise} */ - // istanbul ignore next private async ownWatch(): Promise { const owned = await this.lock(); @@ -1587,14 +1915,13 @@ export class RedisQueue extends EventEmitter for (const script of Object.keys(this.scripts)) { try { - const checksum = sha1(this.scripts[script].code); - - this.scripts[script].checksum = checksum; + // checksums are pre-computed at construction time + const checksum = this.scripts[script].checksum as string; - const scriptExists = await this.writer.script( + const scriptExists = (await this.writer.script( 'EXISTS', checksum, - ) as number[]; + )) as number[]; const loaded = (scriptExists || []).shift(); if (!loaded) { @@ -1604,11 +1931,7 @@ export class RedisQueue extends EventEmitter ); } } catch (err) { - this.emitError( - 'OnScriptLoad', - 'script load error', - err as unknown as Error, - ); + this.emitError('OnScriptLoad', 'script load error', err); } } @@ -1618,33 +1941,20 @@ export class RedisQueue extends EventEmitter } } - // istanbul ignore next /** - * This method returns a watcher lock resolver function + * Attempts to take over an orphaned watcher lock: if the lock is held + * but no watcher connection is actually alive, releases and re-acquires + * ownership. Used to resolve a possible watcher deadlock. * - * @access private - * @param {(...args: any[]) => void} resolve - * @param {(...args: any[]) => void} reject - * @return {() => Promise} + * @returns {Promise} */ - private watchLockResolver( - resolve: (...args: any[]) => void, - reject: (...args: any[]) => void, - ): () => Promise { - return (async () => { - try { - const noWatcher = !await this.watcherCount(); - - if (await this.isLocked() && noWatcher) { - await this.unlock(); - await this.ownWatch(); - } + private async resolveWatchLock(): Promise { + const noWatcher = !(await this.watcherCount()); - resolve(); - } catch (err) { - reject(err); - } - }); + if ((await this.isLocked()) && noWatcher) { + await this.unlock(); + await this.ownWatch(); + } } /** @@ -1653,47 +1963,34 @@ export class RedisQueue extends EventEmitter * * @returns {Promise} */ - // istanbul ignore next private async initWatcher(): Promise { - return new Promise( - (async ( - resolve: (...args: any[]) => void, - reject: (...args: any[]) => void, - ): Promise => { - try { - if (!await this.watcherCount()) { - this.verbose('Initializing watcher...'); - - await this.ownWatch(); - - if (this.watchOwner && this.watcher) { - resolve(); - } else { - // check for possible deadlock to resolve - setTimeout( - this.watchLockResolver( - resolve, - reject, - ) as unknown as () => void, - intrand(1, 50), - ); - } - } else { - resolve(); - } - } catch (err) { - this.logger.error( - `${ this.name }: error initializing watcher, pid ${ - process.pid } on redis host ${ this.redisKey }`, - err, - ); + try { + if (await this.watcherCount()) { + return; + } - reject(err); - } - }) as unknown as ( - resolve: (...args: any[]) => void, - reject: (...args: any[]) => void, - ) => void, - ); + this.verbose('Initializing watcher...'); + + await this.ownWatch(); + + if (this.watchOwner && this.watcher) { + return; + } + + // another instance may hold the lock while its watcher died: + // wait a small random interval (to avoid a thundering herd) and + // try to resolve the possible deadlock + await delay(randomInt(1, 50)); + await this.resolveWatchLock(); + } catch (err) { + this.logger.error( + `${this.name}: error initializing watcher, pid ${ + process.pid + } on redis host ${this.redisKey}`, + err, + ); + + throw err; + } } } diff --git a/src/UDPClusterManager.ts b/src/UDPClusterManager.ts index 73bb159..b67f222 100644 --- a/src/UDPClusterManager.ts +++ b/src/UDPClusterManager.ts @@ -21,11 +21,17 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ +import { join } from 'node:path'; +import { Worker } from 'node:worker_threads'; import { ClusterManager, ICluster } from './ClusterManager'; -import { Worker } from 'worker_threads'; -import * as path from 'path'; +import { ILogger, IServerInput } from './IMessageQueue'; -process.setMaxListeners(10000); +/** Shape of a message posted from the UDP worker thread */ +interface WorkerMessage { + type?: string; + server?: unknown; + error?: string; +} export interface UDPClusterManagerOptions { /** @@ -44,7 +50,7 @@ export interface UDPClusterManagerOptions { address: string; /** - * Message queue limited broadcast address + * Message-queue-limited broadcast address * * @default "255.255.255.255" * @type {string} @@ -70,130 +76,349 @@ export interface UDPClusterManagerOptions { * @type {boolean} */ useAliveCheck: boolean; + + /** + * Enable process signal handling (SIGTERM, SIGINT, SIGABRT) by the + * manager. When enabled, the manager stops its UDP workers on these + * signals and then re-raises the signal, so the process terminates + * through the default signal behavior. Disable if the host application + * manages its own shutdown sequence. + * + * @default true + * @type {boolean} + */ + handleSignals: boolean; + + /** + * Logger used for worker supervision messages + * + * @default console + * @type {ILogger} + */ + logger: ILogger; } +/** + * Subset of the manager options handed over to the UDP worker thread. Only + * structured-cloneable values may appear here (no logger). + */ +export type UDPWorkerOptions = Omit< + UDPClusterManagerOptions, + 'logger' | 'handleSignals' +>; + const IMQ_UDP_CLUSTER_MANAGER_ALIVE_CHECK = !!+( process.env.IMQ_UDP_CLUSTER_MANAGER_ALIVE_CHECK || 1 ); +/** Delay (ms) before an unexpectedly dead worker is re-spawned */ +const WORKER_RESPAWN_DELAY = 1000; + export const DEFAULT_UDP_CLUSTER_MANAGER_OPTIONS: UDPClusterManagerOptions = { port: 63000, address: '255.255.255.255', aliveTimeoutCorrection: 5000, useAliveCheck: IMQ_UDP_CLUSTER_MANAGER_ALIVE_CHECK, + handleSignals: true, + logger: console, }; export class UDPClusterManager extends ClusterManager { private static workers: Record = {}; - public static sockets: Record = {}; + + /** Number of manager instances sharing each worker (by worker key) */ + private static workerRefs: Record = {}; + + /** Live manager instances per worker key, used for re-attachment */ + private static instances: Record> = {}; + + /** True once process-level signal handlers have been registered */ + private static signalsBound: boolean = false; + + /** True while the process is shutting down via a signal */ + private static shuttingDown: boolean = false; + + /** + * Workers we terminated on purpose. worker.terminate() makes the 'exit' + * event fire with a non-zero code, which would otherwise be mistaken for + * a crash and trigger a spurious "exited unexpectedly" warning and respawn. + */ + private static intentionallyStopped: WeakSet = new WeakSet(); + private readonly options: UDPClusterManagerOptions; - private workerKey: string; - private worker: Worker; + private workerKey!: string; + private worker!: Worker; + private destroyed: boolean = false; constructor(options?: Partial) { super(); this.options = { ...DEFAULT_UDP_CLUSTER_MANAGER_OPTIONS, - ...options || {}, + ...options, }; this.startWorkerListener(); - process.on('SIGTERM', UDPClusterManager.free); - process.on('SIGINT', UDPClusterManager.free); - process.on('SIGABRT', UDPClusterManager.free); + if (this.options.handleSignals) { + UDPClusterManager.bindSignals(); + } + } + + private get logger(): ILogger { + return this.options.logger; + } + + /** + * Builds the shared-worker key from every option the worker thread + * depends on, so managers configured differently never silently share + * a worker built from another manager's options. + * + * @param {UDPClusterManagerOptions} options + * @returns {string} + */ + private static workerKeyFor(options: UDPClusterManagerOptions): string { + return [ + options.address, + options.port, + options.limitedAddress ?? '', + options.aliveTimeoutCorrection, + options.useAliveCheck, + ].join('|'); + } + + /** + * Registers process-level shutdown handlers exactly once per process. + * After stopping the workers, the original signal is re-raised, so the + * default termination behavior (which registering a handler cancels) + * still applies, and the process exits. + */ + private static bindSignals(): void { + if (UDPClusterManager.signalsBound) { + return; + } + + UDPClusterManager.signalsBound = true; + + const onSignal = (signal: NodeJS.Signals): void => { + void UDPClusterManager.freeAndRaise(signal); + }; + + process.once('SIGTERM', onSignal); + process.once('SIGINT', onSignal); + process.once('SIGABRT', onSignal); + } + + /** + * Stops all workers and re-raises the given signal, so the process + * terminates through the default signal behavior. + * + * @param {NodeJS.Signals} signal + * @returns {Promise} + */ + private static async freeAndRaise(signal: NodeJS.Signals): Promise { + await UDPClusterManager.free(); + // the once-registered handler is already removed at this point, so + // re-raising hits the default handler and terminates the process + process.kill(process.pid, signal); } private static async free(): Promise { + UDPClusterManager.shuttingDown = true; + const workerKeys = Object.keys(UDPClusterManager.workers); - await Promise.all(workerKeys.map( - workerKey => UDPClusterManager.destroyWorker( - workerKey, - UDPClusterManager.workers[workerKey], - )), + await Promise.all( + workerKeys.map(workerKey => + UDPClusterManager.destroyWorker( + workerKey, + UDPClusterManager.workers[workerKey], + ), + ), ); } + /** + * Registers this instance on the (possibly shared) worker for its + * options. Every instance attaches its own message listener, so all + * managers sharing a worker receive cluster updates. + */ private startWorkerListener(): void { - this.workerKey = `${ this.options.address }:${ this.options.port }`; + this.workerKey = UDPClusterManager.workerKeyFor(this.options); - if (UDPClusterManager.workers[this.workerKey]) { - this.worker = UDPClusterManager.workers[this.workerKey]; + UDPClusterManager.workerRefs[this.workerKey] = + (UDPClusterManager.workerRefs[this.workerKey] || 0) + 1; + (UDPClusterManager.instances[this.workerKey] ??= new Set()).add(this); - return; - } + this.worker = + UDPClusterManager.workers[this.workerKey] || this.spawnWorker(); + this.worker.on('message', this.onWorkerMessage); + } - this.worker = new Worker(path.join(__dirname, './UDPWorker.js'), { - workerData: this.options, + /** + * Spawns and supervises a UDP worker for this manager's options. On an + * unexpected worker death the worker is dropped from the registry, and + * a re-spawn is scheduled while live manager instances remain. + * + * @returns {Worker} + */ + private spawnWorker(): Worker { + const workerData: UDPWorkerOptions = { + port: this.options.port, + address: this.options.address, + limitedAddress: this.options.limitedAddress, + aliveTimeoutCorrection: this.options.aliveTimeoutCorrection, + useAliveCheck: this.options.useAliveCheck, + }; + const worker = new Worker(join(__dirname, './UDPWorker.js'), { + workerData, }); - this.worker.on('message', message => { - const [className, method] = message.type?.split(':'); + const workerKey = this.workerKey; - if (className !== 'cluster') { - return; - } + // many manager instances may listen on one shared worker + worker.setMaxListeners(0); - return this.anyCluster(cluster => { - if (method === 'add') { - try { - const existing = typeof (cluster as any).find === 'function' - ? (cluster as any).find(message.server, true) - : undefined; - if (existing) { - return; - } - } catch {/* ignore */} - } + worker.on('error', err => { + this.logger.error( + `UDPClusterManager: worker ${workerKey} error:`, + err, + ); + }); + worker.on('exit', code => { + if (UDPClusterManager.workers[workerKey] === worker) { + delete UDPClusterManager.workers[workerKey]; + } - const clusterMethod = (cluster as any)[method as keyof ICluster]; + // an exit we caused via terminate() (graceful destroy/shutdown) + // is expected, even though terminate() reports a non-zero code + if (UDPClusterManager.intentionallyStopped.has(worker)) { + UDPClusterManager.intentionallyStopped.delete(worker); - if (!clusterMethod) { - return; - } + return; + } - clusterMethod(message.server); - }); + if (code !== 0 && !UDPClusterManager.shuttingDown) { + this.logger.warn( + `UDPClusterManager: worker ${workerKey} exited ` + + `unexpectedly (code ${code})`, + ); + UDPClusterManager.respawn(workerKey); + } }); - UDPClusterManager.workers[this.workerKey] = this.worker; - } + UDPClusterManager.workers[this.workerKey] = worker; - public async destroy(): Promise { - await UDPClusterManager.destroyWorker(this.workerKey, this.worker); + return worker; } - public static async destroySocket(key: string, socket?: any): Promise { - if (!socket) { + /** + * Schedules a replacement worker for the given key and re-attaches all + * live manager instances to it, so cluster membership does not silently + * freeze after a worker crash. + * + * @param {string} workerKey + */ + private static respawn(workerKey: string): void { + const instances = UDPClusterManager.instances[workerKey]; + + if (UDPClusterManager.shuttingDown || !instances?.size) { return; } - try { - if (typeof socket.removeAllListeners === 'function') { - socket.removeAllListeners(); + const timer = setTimeout(() => { + const [first] = instances; + + if ( + !first || + UDPClusterManager.shuttingDown || + UDPClusterManager.workers[workerKey] + ) { + return; + } + + const worker = first.spawnWorker(); + + for (const instance of instances) { + instance.worker = worker; + worker.on('message', instance.onWorkerMessage); } - } catch (e) { - throw e; + }, WORKER_RESPAWN_DELAY); + + timer.unref(); + } + + /** + * Bound per-instance worker message listener, kept as a field so it can + * be detached from the shared worker when this instance is destroyed. + */ + private readonly onWorkerMessage = (message: WorkerMessage): void => { + void this.handleWorkerMessage(message); + }; + + /** + * Applies a worker cluster message (add/remove) to every registered + * cluster. Cluster callback errors are contained per cluster, so the + * worker message listener can never raise an unhandled rejection. + * + * @param {WorkerMessage} message + * @returns {Promise} + */ + private async handleWorkerMessage(message: WorkerMessage): Promise { + if (message.type === 'error') { + this.logger.warn( + `UDPClusterManager: worker socket error: ${message.error}`, + ); + + return; } - if (typeof socket.close !== 'function') { + const [className, method] = String(message.type ?? '').split(':'); + + if (className !== 'cluster') { return; } - await new Promise((resolve) => { - socket.close(() => { - if (typeof socket.unref === 'function') { - socket.unref(); - } - if (UDPClusterManager.sockets && key in UDPClusterManager.sockets) { - delete UDPClusterManager.sockets[key]; - } - resolve(); - }); + const action = method as keyof ICluster; + + await this.forEachCluster(cluster => { + const server = message.server as IServerInput; + + if (action === 'add' && cluster.find(server)) { + return; + } + + const handler = cluster[action] as + | ((server: IServerInput) => unknown) + | undefined; + + handler?.(server); }); } + public async destroy(): Promise { + if (this.destroyed) { + return; + } + + this.destroyed = true; + this.worker.off('message', this.onWorkerMessage); + UDPClusterManager.instances[this.workerKey]?.delete(this); + + const refs = UDPClusterManager.workerRefs[this.workerKey] ?? 0; + + if (refs > 1) { + // the worker is still shared with other manager instances + // configured the same way — just release this reference + UDPClusterManager.workerRefs[this.workerKey] = refs - 1; + + return; + } + + delete UDPClusterManager.workerRefs[this.workerKey]; + delete UDPClusterManager.instances[this.workerKey]; + await UDPClusterManager.destroyWorker(this.workerKey, this.worker); + } + private static async destroyWorker( workerKey: string, worker?: Worker, @@ -203,22 +428,32 @@ export class UDPClusterManager extends ClusterManager { } return new Promise(resolve => { - const timeout = setTimeout(() => { + const finish = (): void => { + worker.off('message', onMessage); + clearTimeout(timeout); + // mark before terminating: the resulting non-zero exit is + // intentional and must not be treated as a crash + UDPClusterManager.intentionallyStopped.add(worker); worker.terminate(); - resolve(); - }, 5000); - - worker.postMessage({ type: 'stop' }); - worker.once('message', (message) => { - if (message.type === 'stopped') { - clearTimeout(timeout); - worker.terminate(); + if (UDPClusterManager.workers[workerKey] === worker) { delete UDPClusterManager.workers[workerKey]; + } - resolve(); + resolve(); + }; + // a persistent, filtering listener: unrelated cluster messages + // arriving between the stop request and the stop confirmation + // must not consume the wait + const onMessage = (message: WorkerMessage): void => { + if (message.type === 'stopped') { + finish(); } - }); + }; + const timeout = setTimeout(finish, 5000); + + worker.on('message', onMessage); + worker.postMessage({ type: 'stop' }); }); } } diff --git a/src/UDPWorker.ts b/src/UDPWorker.ts index da83b06..13a7644 100644 --- a/src/UDPWorker.ts +++ b/src/UDPWorker.ts @@ -27,13 +27,11 @@ import { parentPort, workerData, MessagePort, -} from 'worker_threads'; -import { createSocket, Socket } from 'dgram'; -import { networkInterfaces } from 'os'; -import { UDPClusterManagerOptions } from './UDPClusterManager'; -import { uuid } from './uuid'; - -process.setMaxListeners(10000); +} from 'node:worker_threads'; +import { createSocket, Socket } from 'node:dgram'; +import { networkInterfaces } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { UDPWorkerOptions } from './UDPClusterManager'; enum MessageType { Up = 'up', @@ -49,25 +47,43 @@ interface Message { timeout: number; } -class UDPWorker { +export class UDPWorker { private readonly socket: Socket; private readonly servers = new Map(); constructor( - private readonly options: UDPClusterManagerOptions, + private readonly options: UDPWorkerOptions, private readonly messagePort: MessagePort, ) { this.setupMessageHandlers(); - this.setupProcessHandlers(); this.socket = createSocket({ type: 'udp4', reuseAddr: true, reusePort: true, - }).bind(this.options.port, this.selectNetworkInterface()); - this.socket.on( - 'message', - message => this.processMessage(this.parseMessage(message)), - ); + }); + // surface socket failures (bind errors, network errors) to the + // main thread instead of crashing the whole process with an + // unhandled 'error' event + this.socket.on('error', err => { + this.messagePort.postMessage({ + type: 'error', + error: err.message, + }); + }); + this.socket.on('message', input => { + // a malformed datagram on the broadcast port must never crash + // the worker (and, through it, the host process) + try { + const message = this.parseMessage(input); + + if (message) { + this.processMessage(message); + } + } catch { + // ignore broken datagrams + } + }); + this.socket.bind(this.options.port, this.selectNetworkInterface()); } private static getServerKey(message: Message): string { @@ -82,12 +98,6 @@ class UDPWorker { }); } - private setupProcessHandlers(): void { - process.on('SIGTERM', this.cleanup); - process.on('SIGINT', this.cleanup); - process.on('SIGABRT', this.cleanup); - } - private addServer(message: Message): void { this.messagePort.postMessage({ type: 'cluster:add', @@ -119,24 +129,25 @@ class UDPWorker { } private serverAliveWait(message: Message): void { - const stamp = uuid(); + const stamp = randomUUID(); const correction = this.options.aliveTimeoutCorrection ?? 0; const effectiveTimeout = message.timeout + correction + 1; const key = UDPWorker.getServerKey(message); this.servers.set(key, stamp); - const t: any = setTimeout(() => setImmediate(() => { - if (this.servers.get(key) === stamp) { - this.removeServer(message); - } - }), effectiveTimeout); - // Avoid keeping the event loop alive due to pending timers - try { - if (t && typeof t.unref === 'function') { - t.unref(); - } - } catch {/* ignore */} + const timer: NodeJS.Timeout = setTimeout( + () => + setImmediate(() => { + if (this.servers.get(key) === stamp) { + this.removeServer(message); + } + }), + effectiveTimeout, + ); + + // a pending liveness timer must not keep the worker alive on its own + timer.unref(); } private processMessage(message: Message): void { @@ -151,13 +162,13 @@ class UDPWorker { private selectNetworkInterface(): string { const interfaces = networkInterfaces(); - const broadcastAddress = this.options.address - || this.options.limitedAddress; + const broadcastAddress = + this.options.address || this.options.limitedAddress; const defaultAddress = '0.0.0.0'; if ( - !broadcastAddress - || broadcastAddress === this.options.limitedAddress + !broadcastAddress || + broadcastAddress === this.options.limitedAddress ) { return defaultAddress; } @@ -168,8 +179,9 @@ class UDPWorker { } for (const net of interfaces[key]) { - const shouldBeSelected = net.family === 'IPv4' - && net.address.startsWith( + const shouldBeSelected = + net.family === 'IPv4' && + net.address.startsWith( broadcastAddress.replace(/\.255/g, ''), ); @@ -182,23 +194,44 @@ class UDPWorker { return defaultAddress; } - private parseMessage(input: Buffer): Message { - const [ - name, - id, - type, - address = '', - timeout = '0', - ] = input.toString().split('\t'); - const [host, port] = address.split(':'); + /** + * Parses a raw broadcast datagram into a message. Returns null for + * malformed input (missing fields, non-numeric port, or timeout), so + * garbage on the broadcast port is dropped instead of producing + * NaN-driven timers or crashes. + * + * @param {Buffer} input + * @returns {Message | null} + */ + private parseMessage(input: Buffer): Message | null { + const [name, id, type, address = '', timeout = '0'] = input + .toString() + .split('\t'); + const [host, port = ''] = address.split(':'); + const portNumber = parseInt(port, 10); + const timeoutMs = parseFloat(timeout) * 1000; + + if ( + !name || + !id || + !type || + !host || + !Number.isFinite(portNumber) || + portNumber <= 0 || + portNumber > 65535 || + !Number.isFinite(timeoutMs) || + timeoutMs < 0 + ) { + return null; + } return { id, name, type: type.toLowerCase() as MessageType, host, - port: parseInt(port), - timeout: parseFloat(timeout) * 1000, + port: portNumber, + timeout: timeoutMs, }; } @@ -220,7 +253,9 @@ class UDPWorker { this.servers.clear(); if (this.socket) { - this.socket.removeAllListeners(); + // keep the 'error' listener attached: a socket error during + // close must not crash the worker as an unhandled 'error' event + this.socket.removeAllListeners('message'); } } } diff --git a/src/helpers/buildOptions.ts b/src/helpers/buildOptions.ts new file mode 100644 index 0000000..3b78a8d --- /dev/null +++ b/src/helpers/buildOptions.ts @@ -0,0 +1,38 @@ +/*! + * Helper: buildOptions + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +/** + * Safely builds full options definition using default options and + * partial given options + * + * @template T + * @param {T} defaultOptions + * @param {Partial} givenOptions + * @returns {T} + */ +export function buildOptions( + defaultOptions: T, + givenOptions?: Partial, +): T { + return Object.assign({}, defaultOptions, givenOptions); +} diff --git a/src/copyEventEmitter.ts b/src/helpers/copyEventEmitter.ts similarity index 86% rename from src/copyEventEmitter.ts rename to src/helpers/copyEventEmitter.ts index 2d4559f..526266f 100644 --- a/src/copyEventEmitter.ts +++ b/src/helpers/copyEventEmitter.ts @@ -21,8 +21,8 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import { EventEmitter } from 'events'; -import * as util from 'util'; +import { EventEmitter } from 'node:events'; +import { inspect } from 'node:util'; export function copyEventEmitter( source: EventEmitter & { @@ -39,9 +39,9 @@ export function copyEventEmitter( const listeners = source.rawListeners(event) as any[]; for (const originalListener of listeners) { - if (util.inspect(originalListener).includes('onceWrapper')) { - const realListener = originalListener?.listener - || originalListener; + if (inspect(originalListener).includes('onceWrapper')) { + const realListener = + originalListener?.listener || originalListener; target.once(event, realListener); } else { diff --git a/src/helpers/envInt.ts b/src/helpers/envInt.ts new file mode 100644 index 0000000..4ba82b1 --- /dev/null +++ b/src/helpers/envInt.ts @@ -0,0 +1,36 @@ +/*! + * Helper: envInt + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +/** + * Reads integer value from environment variable, falling back to the + * given default on missing or non-numeric values + * + * @param {string} name + * @param {number} defaultValue + * @returns {number} + */ +export function envInt(name: string, defaultValue: number): number { + const value = Number(process.env[name] || defaultValue); + + return isNaN(value) ? defaultValue : value; +} diff --git a/src/helpers/escapeRegExp.ts b/src/helpers/escapeRegExp.ts new file mode 100644 index 0000000..05f5bf1 --- /dev/null +++ b/src/helpers/escapeRegExp.ts @@ -0,0 +1,32 @@ +/*! + * Helper: escapeRegExp + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +/** + * Escapes regular expression special characters in a given string + * + * @param {string} str + * @returns {string} + */ +export function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/helpers/index.ts b/src/helpers/index.ts new file mode 100644 index 0000000..9b6fb2f --- /dev/null +++ b/src/helpers/index.ts @@ -0,0 +1,31 @@ +/*! + * IMQ helper functions + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +export * from './buildOptions'; +export * from './sha1'; +export * from './randomInt'; +export * from './pack'; +export * from './unpack'; +export * from './escapeRegExp'; +export * from './copyEventEmitter'; +export * from './envInt'; diff --git a/src/helpers/pack.ts b/src/helpers/pack.ts new file mode 100644 index 0000000..24a45b4 --- /dev/null +++ b/src/helpers/pack.ts @@ -0,0 +1,34 @@ +/*! + * Helper: pack + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { gzipSync } from 'node:zlib'; + +/** + * Compresses the given data and returns a binary string + * + * @param {unknown} data - data to compress + * @returns {string} - compressed data as a binary string + */ +export function pack(data: unknown): string { + return gzipSync(JSON.stringify(data)).toString('binary'); +} diff --git a/src/helpers/randomInt.ts b/src/helpers/randomInt.ts new file mode 100644 index 0000000..1546a71 --- /dev/null +++ b/src/helpers/randomInt.ts @@ -0,0 +1,33 @@ +/*! + * Helper: intrand + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +/** + * Returns random integer between given min and max + * + * @param {number} min + * @param {number} max + * @returns {number} + */ +export function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} diff --git a/src/helpers/sha1.ts b/src/helpers/sha1.ts new file mode 100644 index 0000000..0ca22eb --- /dev/null +++ b/src/helpers/sha1.ts @@ -0,0 +1,38 @@ +/*! + * Helper: sha1 + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { createHash, Hash } from 'node:crypto'; + +/** + * Returns SHA1 hash sum of the given string + * + * @param {string} str + * @returns {string} + */ +export function sha1(str: string): string { + const sha: Hash = createHash('sha1'); + + sha.update(str); + + return sha.digest('hex'); +} diff --git a/src/helpers/unpack.ts b/src/helpers/unpack.ts new file mode 100644 index 0000000..573cd68 --- /dev/null +++ b/src/helpers/unpack.ts @@ -0,0 +1,36 @@ +/*! + * Helper: unpack + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { gunzipSync } from 'node:zlib'; + +/** + * Decompresses a binary string and returns the decompressed data + * + * @param {string} data - compressed data string + * @returns {unknown} - decompressed data + */ +export function unpack(data: string): unknown { + return JSON.parse( + gunzipSync(Buffer.from(data, 'binary')).toString(), + ) as unknown; +} diff --git a/src/index.ts b/src/index.ts index c5c1b5f..c4f6406 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,31 +19,10 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -// noinspection JSUnusedGlobalSymbols -/** - * Safely builds full options definition using default options and - * partial given options - * - * @template T - * @param {T} defaultOptions - * @param {Partial} givenOptions - * @return {T} - */ -// istanbul ignore next -export function buildOptions( - defaultOptions: T, - givenOptions?: Partial, -): T { - return Object.assign({}, defaultOptions, givenOptions); -} - -export * from './IMQMode'; export * from './profile'; -export * from './uuid'; -export * from './promisify'; export * from './redis'; +export * from './IMQMode'; export * from './IMessageQueue'; export * from './RedisQueue'; export * from './ClusteredRedisQueue'; export * from './UDPClusterManager'; -export * from './copyEventEmitter'; diff --git a/src/profile.ts b/src/profile.ts index 2a6c783..ae4eb69 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -21,11 +21,9 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import 'reflect-metadata'; import { ILogger } from '.'; export enum LogLevel { - // noinspection JSUnusedGlobalSymbols LOG = 'log', INFO = 'info', WARN = 'warn', @@ -42,19 +40,19 @@ export interface ProfileDecoratorOptions { */ enableDebugArgs?: boolean; /** - * Defines log/level for logger - * By default is log + * Defines log/level for logger, + * By default, is a log */ logLevel?: LogLevel; } /** - * Checks if log level is set to proper value or returns default one + * Validates a log level value or returns the default * - * @param {*} level - * @return {LogLevel} + * @param {unknown} level - the level to validate + * @returns {LogLevel} - validated log level or INFO if invalid */ -export function verifyLogLevel(level: any): LogLevel { +export function verifyLogLevel(level: unknown): LogLevel { switch (level) { case LogLevel.LOG: case LogLevel.INFO: @@ -75,40 +73,38 @@ const DEFAULT_OPTIONS: ProfileDecoratorOptions = { export type AllowedTimeFormat = 'microseconds' | 'milliseconds' | 'seconds'; /** - * Environment variable IMQ_LOG_TIME=[1, 0] - enables/disables profiled - * timings logging + * Environment variable IMQ_LOG_TIME=[1, 0]. Enable or disable profiled + * timing logging. * * @type {boolean} */ -export const IMQ_LOG_TIME = !!+(process.env.IMQ_LOG_TIME || 0); +export const IMQ_LOG_TIME: boolean = !!+(process.env.IMQ_LOG_TIME || 0); /** - * Environment variable IMQ_LOG_ARGS=[1, 0] - enables/disables profiled - * call arguments to be logged + * Environment variable IMQ_LOG_ARGS=[1, 0]. Enable or disable logging of + * profiled call arguments. * * @type {boolean} */ -export const IMQ_LOG_ARGS = !!+(process.env.IMQ_LOG_ARGS || 0); +export const IMQ_LOG_ARGS: boolean = !!+(process.env.IMQ_LOG_ARGS || 0); /** - * Environment variable IMQ_LOG_TIME_FORMAT=[ - * 'microseconds', - * 'milliseconds', - * 'seconds' - * ]. Specifies profiled time logging format, by default is 'microseconds' + * Environment variable IMQ_LOG_TIME_FORMAT. Specifies the format for profiled + * time logging. Values: 'microseconds', 'milliseconds', 'seconds'. + * Default: 'microseconds' * * @type {AllowedTimeFormat | string} */ export const IMQ_LOG_TIME_FORMAT: AllowedTimeFormat = - process.env.IMQ_LOG_TIME_FORMAT as AllowedTimeFormat || 'microseconds'; + (process.env.IMQ_LOG_TIME_FORMAT as AllowedTimeFormat) || 'microseconds'; export interface DebugInfoOptions { /** - * Turns on/off time debugging + * Enable or disable execution time debugging */ debugTime: boolean; /** - * Turns on/off args debugging + * Enable or disable call arguments debugging */ debugArgs: boolean; /** @@ -118,19 +114,19 @@ export interface DebugInfoOptions { /** * Call arguments */ - args: any[]; + args: unknown[]; /** * Method name */ methodName: string; /** - * Execution start timestamp + * Execution start timestamp (from process.hrtime.bigint or milliseconds) */ - start: any; + start: bigint | number; /** - * Logger implementation + * Logger implementation (absent when the target carries no logger) */ - logger: ILogger; + logger?: ILogger; /** * Log level to use for the call */ @@ -138,16 +134,9 @@ export interface DebugInfoOptions { } /** - * Prints debug information + * Logs debug information about a function call * - * @param {boolean} debugTime - * @param {boolean} debugArgs - * @param {string} className - * @param {any[]} args - * @param {string} methodName - * @param {number} start - * @param {ILogger} logger - * @param {LogLevel} logLevel + * @param {DebugInfoOptions} options - debug information options */ export function logDebugInfo({ debugTime, @@ -159,18 +148,15 @@ export function logDebugInfo({ logger, logLevel, }: DebugInfoOptions) { - const log = logger && typeof logger[logLevel] === 'function' - ? logger[logLevel].bind(logger) : undefined; + const log = + logger && typeof logger[logLevel] === 'function' + ? logger[logLevel].bind(logger) + : undefined; if (debugTime) { - // noinspection TypeScriptUnresolvedFunction - const time = parseInt( - ((process.hrtime as any).bigint() - BigInt(start)) as any, - 10, - ) / 1000; + const time = Number(process.hrtime.bigint() - BigInt(start)) / 1000; let timeStr: string; - // istanbul ignore next switch (IMQ_LOG_TIME_FORMAT) { case 'milliseconds': timeStr = (time / 1000).toFixed(3) + ' ms'; @@ -190,26 +176,30 @@ export function logDebugInfo({ if (debugArgs) { let argStr: string = ''; - const cache: any[] = []; + const cache: unknown[] = []; try { - argStr = JSON.stringify(args, (key: string, value: any) => { - if (typeof value === 'object' && value !== null) { - if (~cache.indexOf(value)) { - try { - return JSON.parse(JSON.stringify(value)); - } catch (error) { - return; + argStr = JSON.stringify( + args, + (_key: string, value: unknown) => { + if (typeof value === 'object' && value !== null) { + if (~cache.indexOf(value)) { + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return; + } } - } - cache.push(value); - } + cache.push(value); + } - return value; - }, 2); + return value; + }, + 2, + ); } catch (err) { - logger.error(err); + logger?.error(err); } if (log) { @@ -218,6 +208,59 @@ export function logDebugInfo({ } } +/** + * Resolves the class name of a decorated call target — the class itself for + * a static call, or the instance's constructor for an instance call. + * + * @param {unknown} target + * @returns {string} + */ +function resolveClassName(target: unknown): string { + if (typeof target === 'function') { + return target.name; + } + + if (typeof target === 'object' && target !== null) { + return ( + (target as { constructor?: { name?: string } }).constructor?.name ?? + '' + ); + } + + return ''; +} + +/** + * Extracts the logger instance from a decorated target, if present. + * + * @param {unknown} target - the decorated target object + * @returns {ILogger | undefined} - the logger instance or undefined + */ +function resolveLogger(target: unknown): ILogger | undefined { + if (typeof target === 'object' && target !== null) { + return (target as { logger?: ILogger }).logger; + } + + return undefined; +} + +/** + * Type guard detecting a thenable (promise-like) value. Generic so that + * narrowing preserves the input type (T & PromiseLike) rather than widening + * to PromiseLike — this keeps the guard usable on a generic return + * value under stricter/older compiler configurations used by consumers. + * + * @param {T} value + * @returns {value is T & PromiseLike} + */ +function isThenable(value: T): value is T & PromiseLike { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ); +} + /** * Implements '@profile' decorator. * @@ -232,24 +275,20 @@ export function logDebugInfo({ * // ... * } * - * @profile() // profiling happened only depending on env DEBUG flag + * @profile() // profiling happens only depending on env DEBUG flag * private innerMethod() { * // ... * } * } * ~~~ * - * @return {( - * target: any, - * methodName: (string), - * descriptor: TypedPropertyDescriptor<(...args: any[]) => any> + * @returns {( + * target: any, + * methodName: (string), + * descriptor: TypedPropertyDescriptor<(...args: any[]) => any>, * ) => void} */ -export function profile(options?: ProfileDecoratorOptions): ( - target: any, - methodName: string, - descriptor: TypedPropertyDescriptor<(...args: any[]) => any>, -) => void { +export function profile(options?: ProfileDecoratorOptions): any { options = Object.assign({}, DEFAULT_OPTIONS, options); const { enableDebugTime, enableDebugArgs, logLevel } = options; @@ -264,47 +303,32 @@ export function profile(options?: ProfileDecoratorOptions): ( debugArgs = enableDebugArgs; } - return function wrapper( - target: any, - methodName: string, - descriptor: TypedPropertyDescriptor<(...args: any[]) => any>, - ) { - /* istanbul ignore next */ - const original = descriptor.value || target[methodName]; - - descriptor.value = function(...args: any[]) { + const wrap = (original: (...args: any[]) => any, methodName: string) => + function wrapper(this: any, ...args: any[]): any { if (!(debugTime || debugArgs)) { - return original.apply(this || target, args); + return original.apply(this, args); } - /* istanbul ignore next */ - const className = typeof target === 'function' && target.name - ? target.name // static - : target.constructor.name; // dynamic - // noinspection TypeScriptUnresolvedFunction - const start = (process.hrtime as any).bigint(); - const result = original.apply(this || target, args); + const className = resolveClassName(this); + const logger = resolveLogger(this); + const start = process.hrtime.bigint(); + const result = original.apply(this, args); const debugOptions: DebugInfoOptions = { args, className, debugArgs, debugTime, logLevel: logLevel ? verifyLogLevel(logLevel) : IMQ_LOG_LEVEL, - logger: (this || target).logger, + logger, methodName, start, }; - /* istanbul ignore next */ - if (result && typeof result.then === 'function') { - // async call detected - result.then((res: any) => { - logDebugInfo(debugOptions); + if (isThenable(result)) { + // async call detected — log once it settles either way + const logAfter = (): void => logDebugInfo(debugOptions); - return res; - }).catch(() => { - logDebugInfo(debugOptions); - }); + result.then(logAfter, logAfter); return result; } @@ -313,5 +337,16 @@ export function profile(options?: ProfileDecoratorOptions): ( return result; }; + + // Dual-mode: standard (TC39) invocations pass a context object with a + // `kind` property; legacy ones pass (target, propertyKey, descriptor). + return function (target: any, context: any, descriptor?: any): any { + if (context && typeof context === 'object' && 'kind' in context) { + return wrap(target, String(context.name)); + } + + descriptor.value = wrap(descriptor.value, String(context)); + + return descriptor; }; } diff --git a/src/promisify.ts b/src/promisify.ts deleted file mode 100644 index 437fe1f..0000000 --- a/src/promisify.ts +++ /dev/null @@ -1,113 +0,0 @@ -/*! - * Makes callback handling function promise-like - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ -/** - * Returns entire list of the given object properties including - * entire prototype chain - * - * @param {any} obj - * @returns {string[]} - */ -export function propertiesOf(obj: any): string[] { - const props: string[] = []; - - // noinspection JSAssignmentUsedAsCondition - do { - Object.getOwnPropertyNames(obj).forEach((prop) => { - if (!~props.indexOf(prop)) { - props.push(prop); - } - }); - } while (obj = Object.getPrototypeOf(obj)); - - return props; -} - -// istanbul ignore next -/** - * Makes a callback function able to resolve or reject with given - * resolve and reject functions - * - * @param {(...args: any[]) => any} resolve - * @param {(...args: any[]) => any} reject - * @return {(...args: any[]) => any} - */ -function makeCallback( - resolve: (...args: any[]) => any, - reject: (...args: any[]) => any, -): (...args: any[]) => any { - return function callback(err: Error, ...args: any[]) { - if (err) { - return reject(err); - } - - resolve(args.length === 1 ? args[0] : args); - }; -} - -// istanbul ignore next -/** - * Makes given method promised - * - * @access private - * @param {(...args: any[]) => any} method - * @return {(...args: any[]) => Promise} - */ -function makePromised(method: (...args: any[]) => any) { - return function asyncMethod(...args: any[]): Promise { - const callback = args[args.length - 1]; - - if (typeof callback === 'function') { - return method.apply(this, args); - } - - return new Promise((resolve, reject) => { - method.call(this, ...args, makeCallback(resolve, reject)); - }); - }; -} - -/** - * Makes given object methods promise-like - * - * @param {any} obj - source object to modify - * @param {string[]} restrict - stick promise-like behavior to a given - * restricted list of methods - * @return {void} - */ -export function promisify(obj: any, restrict?: string[]): void { - for (const prop of propertiesOf(obj)) { - try { - if (typeof obj[prop] !== 'function' || - (restrict && !~restrict.indexOf(prop.toLowerCase())) - ) { - continue; - } - } catch (err) { - /* istanbul ignore next */ - continue; - } - - obj[prop] = makePromised(obj[prop]); - } -} diff --git a/src/redis.ts b/src/redis.ts index f7c47cf..89db614 100644 --- a/src/redis.ts +++ b/src/redis.ts @@ -21,11 +21,10 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -/* tslint:disable */ import Redis from 'ioredis'; /** - * Extends default Redis type to allow dynamic properties access on it + * Extends the default Redis type to allow dynamic properties access on it * * @type {IRedisClient} */ diff --git a/src/uuid.ts b/src/uuid.ts deleted file mode 100644 index 18fe85f..0000000 --- a/src/uuid.ts +++ /dev/null @@ -1,65 +0,0 @@ -/*! - * Unified Unique ID Generator - * Based on solution inspired by Jeff Ward and the comments to it: - * @see http://stackoverflow.com/a/21963136/1511662 - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ -const lookupTable: string[] = []; - -for (let i = 0; i < 256; i++) { - lookupTable[i] = (i < 16 ? '0' : '') + (i).toString(16); -} - -const rand = Math.random.bind(Math); - -/** - * Generates and returns Unified Unique Identifier - * - * @returns {string} - */ -export function uuid(): string { - const d0 = rand() * 0x100000000 >>> 0; - const d1 = rand() * 0x100000000 >>> 0; - const d2 = rand() * 0x100000000 >>> 0; - const d3 = rand() * 0x100000000 >>> 0; - - return lookupTable[d0 & 0xff] + - lookupTable[d0 >> 8 & 0xff] + - lookupTable[d0 >> 16 & 0xff] + - lookupTable[d0 >> 24 & 0xff] + - '-' + - lookupTable[d1 & 0xff] + - lookupTable[d1 >> 8 & 0xff] + - '-' + - lookupTable[d1 >> 16 & 0x0f | 0x40] + - lookupTable[d1 >> 24 & 0xff] + - '-' + - lookupTable[d2 & 0x3f | 0x80] + - lookupTable[d2 >> 8 & 0xff] + - '-' + - lookupTable[d2 >> 16 & 0xff] + - lookupTable[d2 >> 24 & 0xff] + - lookupTable[d3 & 0xff] + - lookupTable[d3 >> 8 & 0xff] + - lookupTable[d3 >> 16 & 0xff] + - lookupTable[d3 >> 24 & 0xff]; -} diff --git a/test/ClusterManager.spec.ts b/test/ClusterManager.spec.ts deleted file mode 100644 index 42b86df..0000000 --- a/test/ClusterManager.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -/*! - * ClusterManager additional tests - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { ClusterManager, InitializedCluster } from '../src/ClusterManager'; - -class TestClusterManager extends ClusterManager { - public destroyed = false; - public constructor() { super(); } - public async destroy(): Promise { - this.destroyed = true; - } -} - -describe('ClusterManager.remove()', () => { - it('should call destroy when the last cluster is removed and destroy=true', async () => { - const cm = new TestClusterManager(); - const cluster: InitializedCluster = cm.init({ - add: () => ({} as any), - remove: () => undefined, - find: () => undefined, - }); - - // sanity: one cluster registered - expect((cm as any).clusters.length).to.equal(1); - const spy = sinon.spy(cm, 'destroy'); - - await cm.remove(cluster, true); - - expect(spy.calledOnce).to.be.true; - expect((cm as any).clusters.length).to.equal(0); - expect(cm.destroyed).to.be.true; - }); - - it('should not call destroy when destroy=false', async () => { - const cm = new TestClusterManager(); - const cluster: InitializedCluster = cm.init({ - add: () => ({} as any), - remove: () => undefined, - find: () => undefined, - }); - - const spy = sinon.spy(cm, 'destroy'); - await cm.remove(cluster.id, false); - - expect(spy.called).to.be.false; - expect((cm as any).clusters.length).to.equal(0); - }); -}); diff --git a/test/ClusteredRedisQueue.addServer.defaultInit.spec.ts b/test/ClusteredRedisQueue.addServer.defaultInit.spec.ts deleted file mode 100644 index e6e229c..0000000 --- a/test/ClusteredRedisQueue.addServer.defaultInit.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -/*! - * ClusteredRedisQueue.addServerWithQueueInitializing default param branch - */ -import './mocks'; -import { expect } from 'chai'; -import { ClusteredRedisQueue } from '../src'; - -describe('ClusteredRedisQueue.addServerWithQueueInitializing() default param', () => { - it('should use default initializeQueue=true when second param omitted', async () => { - const cq: any = new ClusteredRedisQueue('CQ-Default', { - logger: console, - cluster: [{ host: '127.0.0.1', port: 6379 }], - }); - // prevent any actual start/subscription side-effects - (cq as any).state.started = false; - (cq as any).state.subscription = null; - - const server = { host: '192.168.0.1', port: 6380 }; - const initializedSpy = new Promise((resolve) => { - cq['clusterEmitter'].once('initialized', () => resolve()); - }); - - // Call without the second argument to hit default "true" branch - (cq as any).addServerWithQueueInitializing(server); - - await initializedSpy; // should emit initialized when default is true - - // Ensure the server added and queue length updated - expect((cq as any).servers.some((s: any) => s.host === server.host && s.port === server.port)).to.equal(true); - expect((cq as any).imqLength).to.equal((cq as any).imqs.length); - - await cq.destroy(); - }); -}); diff --git a/test/ClusteredRedisQueue.addServer.noInit.spec.ts b/test/ClusteredRedisQueue.addServer.noInit.spec.ts deleted file mode 100644 index df4d6c7..0000000 --- a/test/ClusteredRedisQueue.addServer.noInit.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -/*! - * Cover ClusteredRedisQueue.addServerWithQueueInitializing with initializeQueue=false - */ -import './mocks'; -import { expect } from 'chai'; -import { ClusteredRedisQueue } from '../src'; -import { ClusterManager } from '../src/ClusterManager'; - -const server = { host: '127.0.0.1', port: 6380 }; - -describe('ClusteredRedisQueue.addServerWithQueueInitializing(false)', () => { - it('should add server without initializing queue and not emit initialized', async () => { - const manager = new (ClusterManager as any)(); - const cq: any = new ClusteredRedisQueue('NoInit', { clusterManagers: [manager] }); - - let initializedCalled = false; - (cq as any).clusterEmitter.on('initialized', () => { initializedCalled = true; }); - - // call private method via any to cover branch - (cq as any).addServerWithQueueInitializing(server, false); - - // should have server and imq added - expect(cq.servers.length).to.be.greaterThan(0); - expect(cq.imqs.length).to.be.greaterThan(0); - // queueLength updated - expect(cq.imqLength).to.equal(cq.imqs.length); - // initialized not emitted - expect(initializedCalled).to.equal(false); - - await cq.destroy(); - }); -}); diff --git a/test/ClusteredRedisQueue.extra.spec.ts b/test/ClusteredRedisQueue.extra.spec.ts deleted file mode 100644 index 358d319..0000000 --- a/test/ClusteredRedisQueue.extra.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -/*! - * Additional tests for ClusteredRedisQueue event emitter proxy methods - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { ClusteredRedisQueue } from '../src'; -import { ClusterManager } from '../src/ClusterManager'; - -const clusterConfig = { - cluster: [ - { host: '127.0.0.1', port: 6379 }, - ], -}; - -describe('ClusteredRedisQueue - EventEmitter proxy methods', () => { - it('should cover rawListeners/getMaxListeners/eventNames/listenerCount/emit', async () => { - const clusterManager = new (ClusterManager as any)(); - const cq: any = new ClusteredRedisQueue('ProxyQueue', { - clusterManagers: [clusterManager], - }); - - // add underlying server and listener - cq.addServer(clusterConfig.cluster[0]); - const handler = sinon.spy(); - cq.imqs[0].on('test', handler); - - // set max listeners across emitters and verify getMaxListeners uses templateEmitter - cq.setMaxListeners(20); - expect(cq.getMaxListeners()).to.equal(20); - - // collect raw listeners - const raw = cq.rawListeners('test'); - expect(raw.length).to.be.greaterThan(0); - - // event names come from underlying imq - const names = cq.eventNames(); - expect(names).to.be.an('array'); - expect(names.map(String)).to.include('test'); - - // listener count is aggregated via templateEmitter method applied on imq[0] - expect(cq.listenerCount('test')).to.equal(1); - - // emit should return true - expect(cq.emit('test', 1, 2, 3)).to.equal(true); - expect(handler.calledOnce).to.be.true; - }); -}); diff --git a/test/ClusteredRedisQueue.initialize.spec.ts b/test/ClusteredRedisQueue.initialize.spec.ts deleted file mode 100644 index 90520e8..0000000 --- a/test/ClusteredRedisQueue.initialize.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -/*! - * Additional tests for ClusteredRedisQueue.initializeQueue branches - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { ClusteredRedisQueue, RedisQueue } from '../src'; -import { ClusterManager } from '../src/ClusterManager'; - -describe('ClusteredRedisQueue.initializeQueue()', () => { - it('should call imq.start when started and imq.subscribe when subscription is set', async () => { - const startStub = sinon.stub(RedisQueue.prototype as any, 'start').resolves(undefined); - const subscribeStub = sinon.stub(RedisQueue.prototype as any, 'subscribe').resolves(); - - const clusterManager = new (ClusterManager as any)(); - const cq: any = new ClusteredRedisQueue('InitCover', { clusterManagers: [clusterManager] }); - - // mark started and set subscription using public APIs - await cq.start(); - const channel = 'X'; - const handler = () => undefined; - await cq.subscribe(channel, handler); - - // adding a server triggers initializeQueue which should call start and subscribe - cq.addServer({ host: '127.0.0.1', port: 6453 }); - - // allow promises to resolve - await new Promise(res => setTimeout(res, 0)); - - expect(startStub.called).to.be.true; - expect(subscribeStub.called).to.be.true; - - startStub.restore(); - subscribeStub.restore(); - await cq.destroy(); - }); -}); diff --git a/test/ClusteredRedisQueue.matchServers.spec.ts b/test/ClusteredRedisQueue.matchServers.spec.ts deleted file mode 100644 index 47e9287..0000000 --- a/test/ClusteredRedisQueue.matchServers.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -/*! - * Tests for ClusteredRedisQueue.matchServers combinations - */ -import './mocks'; -import { expect } from 'chai'; -import { ClusteredRedisQueue } from '../src'; - -// Access private static via casting -const match = (ClusteredRedisQueue as any).matchServers as ( - source: any, target: any, strict?: boolean -) => boolean; - -describe('ClusteredRedisQueue.matchServers()', () => { - it('should return sameAddress when no ids provided', () => { - expect(match({ host: 'h', port: 1 }, { host: 'h', port: 1 })).to.be.true; - expect(match({ host: 'h', port: 1 }, { host: 'h', port: 2 })).to.be.false; - }); - - it('should match servers if id provided', () => { - expect(match({ id: 'a', host: 'h', port: 1 }, { id: 'a', host: 'h', port: 2 })).to.be.true; - expect(match({ id: 'a', host: 'h', port: 1 }, { id: 'b', host: 'h', port: 1 })).to.be.true; - }); -}); diff --git a/test/ClusteredRedisQueue.ts b/test/ClusteredRedisQueue.ts deleted file mode 100644 index 3d37e8e..0000000 --- a/test/ClusteredRedisQueue.ts +++ /dev/null @@ -1,344 +0,0 @@ -/*! - * RedisQueue Unit Tests - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ -import { logger } from './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { ClusteredRedisQueue } from '../src'; -import { ClusterManager } from '../src/ClusterManager'; - -process.setMaxListeners(100); - -const clusterConfig = { - logger, - cluster: [{ - host: '127.0.0.1', - port: 7777 - }, { - host: '127.0.0.1', - port: 8888 - }] -}; - -describe('ClusteredRedisQueue', function() { - this.timeout(30000); - - it('should be a class', () => { - expect(typeof ClusteredRedisQueue).to.equal('function'); - }); - - it('should implement IMessageQueue interface', () => { - expect(typeof ClusteredRedisQueue.prototype.start) - .to.equal('function'); - expect(typeof ClusteredRedisQueue.prototype.stop) - .to.equal('function'); - expect(typeof ClusteredRedisQueue.prototype.send) - .to.equal('function'); - expect(typeof ClusteredRedisQueue.prototype.destroy) - .to.equal('function'); - }); - - describe('constructor()', () => { - it('should throw with improper options passed', () => { - expect(() => new ClusteredRedisQueue('TestClusteredQueue')) - .to.throw(TypeError); - }); - - it('should not throw if proper options passed', () => { - expect(() => new ClusteredRedisQueue( - 'TestClusteredQueue', - clusterConfig - )).not.to.throw(TypeError); - }); - - it('should initialize cluster manager', () => { - const clusterManager = new (ClusterManager as any)(); - - sinon.spy(clusterManager, 'init'); - - new ClusteredRedisQueue( - 'TestClusteredQueue', - { clusterManagers: [clusterManager] }, - ); - - expect(clusterManager.init.called).to.be.true; - }); - }); - - describe('start()', () => { - it('should start each nested imq', async () => { - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - clusterConfig - ); - - cq.imqs.forEach((imq: any) => { - sinon.spy(imq, 'start'); - }); - - await cq.start(); - - cq.imqs.forEach((imq: any) => { - expect(imq.start.called).to.be.true; - }); - - await cq.destroy(); - }); - }); - - describe('stop()', () => { - it('should stop each nested imq', async () => { - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - clusterConfig - ); - - cq.imqs.forEach((imq: any) => { - sinon.spy(imq, 'stop'); - }); - - await cq.stop(); - - cq.imqs.forEach((imq: any) => { - expect(imq.stop.called).to.be.true; - }); - - await cq.destroy(); - }); - }); - - describe('send()', () => { - it('should balance send requests round-robin manner across nested ' + - 'queues', async () => { - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - clusterConfig - ); - - cq.imqs.forEach((imq: any) => { - sinon.spy(imq, 'send'); - }); - - await cq.send('TestClusteredQueue', { 'hello': 'world' }); - - expect(cq.imqs[0].send.calledOnce).to.be.true; - expect(cq.imqs[1].send.called).to.be.false; - - await cq.send('TestClusteredQueue', { 'hello': 'world' }); - - expect(cq.imqs[0].send.calledOnce).to.be.true; - expect(cq.imqs[1].send.calledOnce).to.be.true; - - await cq.send('TestClusteredQueue', { 'hello': 'world' }); - - expect(cq.imqs[0].send.calledTwice).to.be.true; - expect(cq.imqs[1].send.calledOnce).to.be.true; - - await cq.destroy(); - }); - - it('should send message after queue was initialized', done => { - const clusterManager = new (ClusterManager as any)(); - const cqOne: any = new ClusteredRedisQueue( - 'TestClusteredQueueOne', - { - clusterManagers: [clusterManager], - logger, - }, - ); - const cqTwo: any = new ClusteredRedisQueue( - 'TestClusteredQueueTwo', - { - clusterManagers: [clusterManager], - logger, - }, - ); - const message = { 'hello': 'world' }; - - cqOne.start(); - cqTwo.start(); - - cqTwo.on('message', () => { - cqOne.destroy(); - cqTwo.destroy(); - - done(); - }); - - cqOne.send('TestClusteredQueueTwo', message); - cqTwo.addServer(clusterConfig.cluster[0]); - cqOne.addServer(clusterConfig.cluster[0]); - }); - }); - - describe('destroy()', () => { - it('should destroy each nested imq', async () => { - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - clusterConfig - ); - - cq.imqs.forEach((imq: any) => { - sinon.spy(imq, 'destroy'); - }); - - await cq.destroy(); - - cq.imqs.forEach((imq: any) => { - expect(imq.destroy.called).to.be.true; - }); - }); - }); - - describe('clear()', () => { - it('should clear each nested imq', async () => { - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - clusterConfig - ); - - cq.imqs.forEach((imq: any) => { - sinon.spy(imq, 'clear'); - }); - - await cq.clear(); - - cq.imqs.forEach((imq: any) => { - expect(imq.clear.called).to.be.true; - }); - - await cq.destroy(); - }); - }); - - describe('subscribe()', () => { - it('should subscribe after queue initialization', () => { - const clusterManager = new (ClusterManager as any)(); - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - { - clusterManagers: [clusterManager], - logger, - }, - ); - const channel = 'TestChannel'; - - cq.subscribe(channel, () => {}); - cq.addServer(clusterConfig.cluster[0]); - - expect(cq.imqs[0].subscriptionName).to.be.equal(channel); - }); - }); - - describe('addServer()', () => { - it('should add cluster server', () => { - const clusterManager = new (ClusterManager as any)(); - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - { clusterManagers: [clusterManager] }, - ); - - cq.addServer(clusterConfig.cluster[0]); - - expect(cq.servers.length).to.be.equal(1); - }); - - it('should call adding cluster server method through the' - + ' Cluster Manager', () => { - const clusterManager = new (ClusterManager as any)(); - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - { clusterManagers: [clusterManager] }, - ); - - for (const server of clusterManager.clusters) { - server.add(clusterConfig.cluster[0]); - } - - expect(cq.servers.length).to.be.equal(1); - }); - }); - - describe('removeServer()', () => { - it('should remove cluster server', () => { - const clusterManager = new (ClusterManager as any)(); - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - { clusterManagers: [clusterManager] }, - ); - - cq.addServer(clusterConfig.cluster[0]); - cq.removeServer(clusterConfig.cluster[0]); - - expect(cq.servers.length).to.be.equal(0); - }); - - it('should call removing cluster server method through the' - + ' Cluster Manager', () => { - const clusterManager = new (ClusterManager as any)(); - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - { clusterManagers: [clusterManager] }, - ); - - for (const server of clusterManager.clusters) { - server.remove(clusterConfig.cluster[0]); - } - - expect(cq.servers.length).to.be.equal(0); - }); - }); - - describe('findServer()', () => { - it('should find cluster server', () => { - const clusterManager = new (ClusterManager as any)(); - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - { clusterManagers: [clusterManager] }, - ); - - cq.addServer(clusterConfig.cluster[0]); - - const server = cq.findServer(clusterConfig.cluster[0]); - - expect(server).to.deep.include(clusterConfig.cluster[0]); - }); - - it('should call find cluster server method through the' - + ' Cluster Manager', () => { - const clusterManager = new (ClusterManager as any)(); - const cq: any = new ClusteredRedisQueue( - 'TestClusteredQueue', - { clusterManagers: [clusterManager] }, - ); - - cq.addServer(clusterConfig.cluster[0]); - - for (const cluster of clusterManager.clusters) { - const server = cluster.find(clusterConfig.cluster[0]); - - expect(server).to.deep.include(clusterConfig.cluster[0]); - } - }); - }); -}); diff --git a/test/RedisQueue.cleanup.catch.spec.ts b/test/RedisQueue.cleanup.catch.spec.ts deleted file mode 100644 index 8066318..0000000 --- a/test/RedisQueue.cleanup.catch.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -/*! - * Additional RedisQueue tests: processCleanup catch branch - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { RedisQueue } from '../src'; -import { logger as testLogger } from './mocks'; - -function makeLogger() { - return { - log: (..._args: any[]) => undefined, - info: (..._args: any[]) => undefined, - warn: (..._args: any[]) => undefined, - error: (..._args: any[]) => undefined, - } as any; -} - -describe('RedisQueue.processCleanup catch path', function() { - this.timeout(10000); - - it('should log a warning when processCleanup throws', async () => { - const logger = makeLogger(); - const warnSpy = sinon.spy(logger, 'warn'); - const rq: any = new RedisQueue('CleanupCatch', { - logger, - cleanup: true, - }); - - await rq.start(); - // Stub writer.client to throw to hit the catch branch - const stub = sinon.stub(rq.writer, 'client').throws(new Error('LIST failed')); - - await rq.processCleanup(); - - expect(warnSpy.called).to.be.true; - - stub.restore(); - await rq.destroy(); - }); -}); diff --git a/test/RedisQueue.connect.fallbacks.spec.ts b/test/RedisQueue.connect.fallbacks.spec.ts deleted file mode 100644 index 60cb217..0000000 --- a/test/RedisQueue.connect.fallbacks.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/*! - * Additional RedisQueue tests: connect() option fallbacks branches - */ -import './mocks'; -import { expect } from 'chai'; -import { RedisQueue, IMQMode } from '../src'; - -function makeLogger() { - return { - log: (..._args: any[]) => undefined, - info: (..._args: any[]) => undefined, - warn: (..._args: any[]) => undefined, - error: (..._args: any[]) => undefined, - } as any; -} - -describe('RedisQueue.connect() option fallbacks', function() { - this.timeout(10000); - - it('should use fallback values when falsy options are provided', async () => { - const logger = makeLogger(); - // Intentionally provide falsy values to trigger `||` fallbacks in connect() - const rq: any = new RedisQueue('ConnFallbacks', { - logger, - port: 0 as unknown as number, // falsy to trigger 6379 fallback - host: '' as unknown as string, // falsy to trigger 'localhost' fallback - prefix: '' as unknown as string, // falsy to trigger '' fallback in connectionName - cleanup: false, - }, IMQMode.BOTH); - - await rq.start(); - - // Basic sanity: writer/reader/watcher are created - expect(Boolean(rq.writer)).to.equal(true); - expect(Boolean(rq.reader)).to.equal(true); - expect(Boolean(rq.watcher)).to.equal(true); - - await rq.destroy(); - }); -}); diff --git a/test/RedisQueue.processCleanup.clientsFilter.spec.ts b/test/RedisQueue.processCleanup.clientsFilter.spec.ts deleted file mode 100644 index 98bde70..0000000 --- a/test/RedisQueue.processCleanup.clientsFilter.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -/*! - * Additional RedisQueue tests: processCleanup connectedKeys filter branches - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { RedisQueue, uuid } from '../src'; - -describe('RedisQueue.processCleanup connectedKeys RX/filter combinations', function() { - this.timeout(5000); - - it('should handle RX_CLIENT_TEST true but filter false case (exclude unmatched prefix)', async () => { - const name = `PCleanRX_${uuid()}`; - const rq: any = new RedisQueue(name, { - logger: console, - cleanup: true, - prefix: 'imqA', - cleanupFilter: '*', - }); - - await rq.start(); - - const writer: any = rq.writer; - - // Stub client('LIST') to include a writer channel with a different prefix, - // so RX_CLIENT_TEST.test(name) is true but filter.test(name) is false. - const clientStub = sinon.stub(writer, 'client'); - clientStub.callsFake(async (cmd: string) => { - if (cmd === 'LIST') { - return [ - 'id=1 name=imqZ:Other:writer:pid:1:host:x', // RX true, filter false - 'id=2 name=imqA:Other:subscription:pid:1:host:x', // RX false, filter true - ].join('\n'); - } - return true as any; - }); - - // Return no keys on SCAN to avoid deletions and just walk the branch - const scanStub = sinon.stub(writer, 'scan'); - scanStub.resolves(['0', []] as any); - - const delSpy = sinon.spy(writer, 'del'); - - await rq.processCleanup(); - - expect(delSpy.called).to.equal(false); - - clientStub.restore(); - scanStub.restore(); - delSpy.restore(); - await rq.destroy(); - }); -}); diff --git a/test/RedisQueue.processCleanup.extra.spec.ts b/test/RedisQueue.processCleanup.extra.spec.ts deleted file mode 100644 index 761a27f..0000000 --- a/test/RedisQueue.processCleanup.extra.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -/*! - * Additional RedisQueue tests for processCleanup branches - */ -import './mocks'; -import { expect } from 'chai'; -import { RedisQueue, uuid } from '../src'; -import { RedisClientMock } from './mocks'; - -describe('RedisQueue.processCleanup extra branches', function() { - this.timeout(5000); - - it('should remove scanned keys that do not match any connectedKey (different prefix)', async () => { - const name = uuid(); - const rq: any = new RedisQueue(name, { - logger: console, - cleanup: true, - prefix: 'imqX', - cleanupFilter: '*', - }); - - // start to create reader/writer/watcher with connection names - await rq.start(); - - // Create an orphan worker key with a different prefix so it won't include any connectedKey - const orphanKey = 'imqY:orphan:worker:someuuid:123456'; - (RedisClientMock as any).__queues__[orphanKey] = ['payload']; - - // Sanity: ensure the key is present before cleanup - expect((RedisClientMock as any).__queues__[orphanKey]).to.be.ok; - - await rq.processCleanup(); - - // The orphan key should be deleted by cleanup (true branch of keysToRemove filter) - expect((RedisClientMock as any).__queues__[orphanKey]).to.be.undefined; - - await rq.destroy(); - }); -}); diff --git a/test/RedisQueue.processCleanup.multiscan.spec.ts b/test/RedisQueue.processCleanup.multiscan.spec.ts deleted file mode 100644 index db7825c..0000000 --- a/test/RedisQueue.processCleanup.multiscan.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -/*! - * Additional RedisQueue tests for processCleanup branches: multi-scan and no-deletion path - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { RedisQueue, uuid } from '../src'; - -describe('RedisQueue.processCleanup multi-scan/no-delete branches', function() { - this.timeout(5000); - - it('should handle multi-page SCAN (cursor != "0" first) and avoid deletion when keys belong to connected clients', async () => { - const name = `PClean_${uuid()}`; - const rq: any = new RedisQueue(name, { - logger: console, - cleanup: true, - prefix: 'imq', - cleanupFilter: '*', - }); - - await rq.start(); - - const writer: any = rq.writer; - - // Stub scan to first return non-zero cursor with undefined keys (to exercise `|| []`), - // then return zero cursor with keys that include connectedKey (so no removal happens). - const scanStub = sinon.stub(writer, 'scan'); - scanStub.onCall(0).resolves(['1', undefined] as any); - scanStub.onCall(1).resolves(['0', [`imq:${name}:reader:pid:123`]] as any); - - const delSpy = sinon.spy(writer, 'del'); - - await rq.processCleanup(); - - // del should not be called because keysToRemove.length === 0 - expect(delSpy.called).to.equal(false); - - scanStub.restore(); - delSpy.restore(); - await rq.destroy(); - }); -}); diff --git a/test/RedisQueue.processCleanup.nullmatch.spec.ts b/test/RedisQueue.processCleanup.nullmatch.spec.ts deleted file mode 100644 index e90b32e..0000000 --- a/test/RedisQueue.processCleanup.nullmatch.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -/*! - * Additional RedisQueue tests for processCleanup branches: clients.match null and cleanupFilter falsy - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { RedisQueue, uuid } from '../src'; - -describe('RedisQueue.processCleanup null-match and falsy cleanupFilter', function() { - this.timeout(5000); - - it('should handle clients.match returning null and cleanupFilter as falsy (\'\')', async () => { - const name = `PCleanNull_${uuid()}`; - const rq: any = new RedisQueue(name, { - logger: console, - cleanup: true, - prefix: 'imq', - cleanupFilter: '', // falsy to exercise "|| '*'" in both RegExp and SCAN MATCH - }); - - await rq.start(); - - const writer: any = rq.writer; - - // Force clients.match(...) to return null by stubbing client('LIST') to return a string without 'name=' - const clientStub = sinon.stub(writer, 'client'); - clientStub.callsFake(async (cmd: string) => { - if (cmd === 'LIST') { - return 'id=1 flags=x'; // no 'name=' - } - return true as any; - }); - - // Ensure SCAN returns no keys, to avoid deletions and just cover the branch paths - const scanStub = sinon.stub(writer, 'scan'); - scanStub.resolves(['0', []] as any); - - const delSpy = sinon.spy(writer, 'del'); - - await rq.processCleanup(); - - expect(delSpy.called).to.equal(false); - - clientStub.restore(); - scanStub.restore(); - delSpy.restore(); - await rq.destroy(); - }); -}); diff --git a/test/RedisQueue.processDelayed.catch.spec.ts b/test/RedisQueue.processDelayed.catch.spec.ts deleted file mode 100644 index 82fa5ca..0000000 --- a/test/RedisQueue.processDelayed.catch.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/*! - * Additional RedisQueue tests: processDelayed() catch branch - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { RedisQueue } from '../src'; - -function makeLogger() { - return { - log: (..._args: any[]) => undefined, - info: (..._args: any[]) => undefined, - warn: (..._args: any[]) => undefined, - error: (..._args: any[]) => undefined, - } as any; -} - -describe('RedisQueue.processDelayed extra branches', function() { - this.timeout(10000); - - it('should emit error when evalsha throws', async () => { - const logger = makeLogger(); - const rq: any = new RedisQueue('ProcessDelayedCatch', { logger }); - await rq.start(); - - // Force checksum to exist to enter evalsha path - rq.scripts.moveDelayed.checksum = 'deadbeef'; - - const err = new Error('evalsha failed'); - const emitErrorStub = sinon.stub((RedisQueue as any).prototype, 'emitError'); - - // Temporarily drop writer to force a synchronous error in processDelayed - const originalWriter = rq.writer; - rq['writer'] = undefined; - - await rq['processDelayed'](rq.key); - - expect(emitErrorStub.called).to.be.true; - expect(emitErrorStub.firstCall.args[0]).to.equal('OnProcessDelayed'); - - // Restore writer and cleanup - rq['writer'] = originalWriter; - emitErrorStub.restore(); - await rq.destroy(); - }); -}); diff --git a/test/RedisQueue.publish.spec.ts b/test/RedisQueue.publish.spec.ts deleted file mode 100644 index b4f48d7..0000000 --- a/test/RedisQueue.publish.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -/*! - * Additional RedisQueue tests: publish() branches - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { RedisQueue, IMQMode } from '../src'; - -function makeLogger() { - return { - log: (..._args: any[]) => undefined, - info: (..._args: any[]) => undefined, - warn: (..._args: any[]) => undefined, - error: (..._args: any[]) => undefined, - } as any; -} - -describe('RedisQueue.publish()', function() { - this.timeout(10000); - - it('should throw when writer is not connected', async () => { - const logger = makeLogger(); - const rq: any = new RedisQueue('PubNoWriter', { logger }, IMQMode.PUBLISHER); - - let thrown: any; - try { - await rq.publish({ a: 1 }); - } catch (err) { - thrown = err; - } - - expect(thrown).to.be.instanceof(TypeError); - expect(`${thrown}`).to.include('Writer is not connected'); - - await rq.destroy().catch(() => undefined); - }); - - it('should publish to default channel when writer is connected', async () => { - const logger = makeLogger(); - const rq: any = new RedisQueue('PubDefault', { logger }, IMQMode.PUBLISHER); - await rq.start(); - - const pubSpy = sinon.spy((rq as any).writer, 'publish'); - await rq.publish({ hello: 'world' }); - - expect(pubSpy.called).to.equal(true); - const [channel, msg] = pubSpy.getCall(0).args; - expect(channel).to.equal('imq:PubDefault'); - expect(() => JSON.parse(msg)).not.to.throw(); - - pubSpy.restore(); - await rq.destroy().catch(() => undefined); - }); - - it('should publish to provided toName channel when given', async () => { - const logger = makeLogger(); - const rq: any = new RedisQueue('PubOther', { logger }, IMQMode.PUBLISHER); - await rq.start(); - - const pubSpy = sinon.spy((rq as any).writer, 'publish'); - await rq.publish({ t: true }, 'OtherChannel'); - - expect(pubSpy.called).to.equal(true); - const [channel] = pubSpy.getCall(0).args; - expect(channel).to.equal('imq:OtherChannel'); - - pubSpy.restore(); - await rq.destroy().catch(() => undefined); - }); -}); diff --git a/test/RedisQueue.send.extra.branches.spec.ts b/test/RedisQueue.send.extra.branches.spec.ts deleted file mode 100644 index 36da896..0000000 --- a/test/RedisQueue.send.extra.branches.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -/*! - * Additional RedisQueue tests: send() extra branches - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { RedisQueue, IMQMode } from '../src'; -import { logger as testLogger } from './mocks'; - -function makeLogger() { - return { - log: (..._args: any[]) => undefined, - info: (..._args: any[]) => undefined, - warn: (..._args: any[]) => undefined, - error: (..._args: any[]) => undefined, - } as any; -} - -describe('RedisQueue.send() extra branches', function() { - this.timeout(10000); - - it('should throw when writer is still uninitialized after start()', async () => { - const logger = makeLogger(); - const rq: any = new RedisQueue('SendNoWriter', { logger }, IMQMode.PUBLISHER); - // Force start to be a no-op so writer remains undefined - const startStub = sinon.stub(rq, 'start').resolves(rq); - - let thrown: any; - try { - await rq.send('AnyQueue', { test: true }); - } catch (err) { - thrown = err; - } - - expect(thrown).to.be.instanceof(TypeError); - expect(`${thrown}`).to.include('unable to initialize queue'); - - startStub.restore(); - await rq.destroy().catch(() => undefined); - }); -}); diff --git a/test/RedisQueue.send.worker.mode.spec.ts b/test/RedisQueue.send.worker.mode.spec.ts deleted file mode 100644 index 213e987..0000000 --- a/test/RedisQueue.send.worker.mode.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -/*! - * Additional RedisQueue tests: send() worker-only mode error - */ -import './mocks'; -import { expect } from 'chai'; -import { RedisQueue, IMQMode } from '../src'; - -function makeLogger() { - return { - log: (..._args: any[]) => undefined, - info: (..._args: any[]) => undefined, - warn: (..._args: any[]) => undefined, - error: (..._args: any[]) => undefined, - } as any; -} - -describe('RedisQueue.send() worker-only mode', function() { - this.timeout(10000); - - it('should throw when called in WORKER only mode', async () => { - const logger = makeLogger(); - const rq: any = new RedisQueue('WorkerOnly', { logger }, IMQMode.WORKER); - - let thrown: any; - try { - await rq.send('AnyQueue', { test: true }); - } catch (err) { - thrown = err; - } - - expect(thrown).to.be.instanceof(TypeError); - expect(`${thrown}`).to.include('WORKER only mode'); - - await rq.destroy().catch(() => undefined); - }); -}); diff --git a/test/RedisQueue.ts b/test/RedisQueue.ts deleted file mode 100644 index 71c6c98..0000000 --- a/test/RedisQueue.ts +++ /dev/null @@ -1,342 +0,0 @@ -/*! - * RedisQueue Unit Tests - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ -import { logger } from './mocks'; -import { expect } from 'chai'; -import { RedisQueue, uuid, IMQMode } from '../src'; -import Redis from 'ioredis'; - -process.setMaxListeners(100); - -describe('RedisQueue', function() { - this.timeout(30000); - - it('should be a class', () => { - expect(typeof RedisQueue).to.equal('function'); - }); - - it('should implement IMessageQueue interface', () => { - expect(typeof RedisQueue.prototype.start).to.equal('function'); - expect(typeof RedisQueue.prototype.stop).to.equal('function'); - expect(typeof RedisQueue.prototype.send).to.equal('function'); - expect(typeof RedisQueue.prototype.destroy).to.equal('function'); - }); - - describe('constructor()', () => { - it('should not throw', async () => { - const instances: RedisQueue[] = []; - expect(() => instances.push(new (RedisQueue)())).not.to.throw(Error); - expect(() => instances.push(new RedisQueue('IMQUnitTests'))).not.to.throw(Error); - expect(() => instances.push(new RedisQueue('IMQUnitTests', {}))) - .not.to.throw(Error); - expect(() => instances.push(new RedisQueue('IMQUnitTests', { useGzip: true }))) - .not.to.throw(Error); - - await Promise.all(instances.map(instance => instance.destroy())); - }); - }); - - describe('start()', () => { - it('should throw if no name provided', async () => { - const rq = new (RedisQueue)(); - try { await rq.start() } - catch (err) { expect(err).to.be.instanceof(TypeError) } - rq.destroy().catch(); - }); - - it('should create reader connection', async () => { - try { - const rq: any = new RedisQueue(uuid(), { logger }); - await rq.start(); - expect(rq.reader).to.be.instanceof(Redis); - await rq.destroy(); - } - - catch (err) { console.error(err) } - }); - - it('should create shared writer connection', async () => { - const rq: any = new RedisQueue(uuid(), { logger }); - await rq.start(); - expect(rq.writer).to.be.instanceof(Redis); - await rq.destroy(); - }); - - it('should create single watcher connection', async () => { - const rq1: any = new RedisQueue(uuid(), { logger }); - const rq2: any = new RedisQueue(uuid(), { logger }); - await rq1.start(); - await rq2.start(); - expect(await rq1.watcherCount()).to.be.equal(1); - expect(await rq2.watcherCount()).to.be.equal(1); - await rq1.destroy(); - await rq2.destroy(); - }); - - it('should restart stopped queue', async () => { - const rq: any = new RedisQueue(uuid(), { logger }); - await rq.start(); - await rq.stop(); - await rq.start(); - expect(rq.reader).to.be.instanceof(Redis); - await rq.destroy(); - }); - - it('should not fail on double start', async () => { - const rq: any = new RedisQueue(uuid(), { logger }); - let passed = true; - try { - await rq.start(); - await rq.start(); - } catch (err) { passed = false } - expect(passed).to.be.true; - await rq.destroy(); - }); - }); - - describe('stop()', () => { - it('should stop reading messages from queue', async () => { - const name = uuid(); - const rq: any = new RedisQueue(name, { logger }); - await rq.start(); - expect(rq.reader).to.be.instanceof(Redis); - await rq.stop(); - expect(rq.reader).not.to.be.ok; - await rq.destroy(); - }); - }); - - describe('send()', () => { - it('should send given message to a given queue', (done) => { - const message: any = { hello: 'world' }; - const rqFrom = new RedisQueue('IMQUnitTestsFrom', { logger }); - const rqTo = new RedisQueue('IMQUnitTestsTo', { logger }); - - rqTo.on('message', (msg, id, from) => { - expect(msg).to.deep.equal(message); - expect(id).not.to.be.undefined; - expect(from).to.equal('IMQUnitTestsFrom'); - rqFrom.destroy().catch(); - rqTo.destroy().catch(); - done(); - }); - - rqFrom.start().then(() => { rqTo.start().then(() => { - rqFrom.send('IMQUnitTestsTo', message).catch(); - });}); - }); - - it('should guaranty message delivery if safeDelivery is on', (done) => { - // it is hard to emulate mq crash at a certain time of - // its runtime execution, so we simply assume delivery works itself - // for the moment. dumb test but better than nothing :( - const message: any = { hello: 'safe delivery' }; - const rq = new RedisQueue('IMQSafe', { - logger, safeDelivery: true, - }); - - rq.on('message', (msg) => { - expect(msg).to.deep.equal(message); - rq.destroy().catch(); - done(); - }); - - rq.start().then(async () => rq.send('IMQSafe', message)); - }); - - it('should deliver message with the given delay', (done) => { - const message: any = { hello: 'world' }; - const delay: number = 1000; - const rqFrom = new RedisQueue('IMQUnitTestsFromD', { logger }); - const rqTo = new RedisQueue('IMQUnitTestsToD', { logger }); - - let start: number; - - rqTo.on('message', (msg, id, from) => { - expect(Date.now() - start).to.be.gte(delay); - expect(msg).to.deep.equal(message); - expect(id).not.to.be.undefined; - expect(from).to.equal('IMQUnitTestsFromD'); - rqFrom.destroy().catch(); - rqTo.destroy().catch(); - done(); - }); - - rqFrom.start().then(() => { rqTo.start().then(() => { - start = Date.now(); - rqFrom.send('IMQUnitTestsToD', message, delay).catch(); - });}); - }); - - it('should trigger an error in case of redis error', (done) => { - const lrange = Redis.prototype.lrange; - let wasDone = false; - Redis.prototype.lrange = async (): Promise => - [undefined, undefined] as unknown as string[]; - - const message: any = { hello: 'safe delivery' }; - const rq = new RedisQueue('IMQSafe', { - logger, safeDelivery: true - }); - - process.on('unhandledRejection', function(e) { - expect((e as any).message).to.be.equal('Wrong messages count'); - Redis.prototype.lrange = lrange; - !wasDone && done(); - wasDone = true; - }); - - rq.start().then(() => rq.send('IMQSafe', message)); - }); - }); - - describe('destroy()', () => { - let rq: any; - - beforeEach(async () => { - rq = new RedisQueue(uuid(), { logger }); - await rq.start(); - }); - - it('should destroy all connections', async () => { - await rq.destroy(); - expect(rq.watcher).not.to.be.ok; - expect(rq.reader).not.to.be.ok; - expect(rq.writer).not.to.be.ok; - }); - - it('should remove all event listeners', async () => { - await rq.destroy(); - expect(rq.listenerCount()).to.equal(0); - }); - }); - - describe('clear()', () => { - it('should clean-up queue data in redis', async () => { - const rq: any = new RedisQueue(uuid(), { logger }); - await rq.start(); - rq.clear(); - expect(await rq.writer.exists(rq.key)) - .not.to.be.ok; - expect(await rq.writer.exists(`${rq.key}:delayed`)) - .not.to.be.ok; - rq.destroy().catch(); - }); - }); - - describe('processCleanup()', () => { - it('should perform cleanup when cleanup option is enabled', async () => { - const rq: any = new RedisQueue(uuid(), { - logger, - cleanup: true, - cleanupFilter: 'test*' - }); - await rq.start(); - - // Call processCleanup directly - const result = await rq.processCleanup(); - expect(result).to.equal(rq); - - await rq.destroy(); - }); - - it('should return early when cleanup option is disabled', async () => { - const rq: any = new RedisQueue(uuid(), { - logger, - cleanup: false - }); - await rq.start(); - - const result = await rq.processCleanup(); - expect(result).to.be.undefined; - - await rq.destroy(); - }); - }); - - describe('lock/unlock methods', () => { - it('should handle lock/unlock when writer is null', async () => { - const rq: any = new RedisQueue(uuid(), { logger }); - // Don't start, so writer will be null - - const lockResult = await rq.lock(); - expect(lockResult).to.be.false; - - const unlockResult = await rq.unlock(); - expect(unlockResult).to.be.false; - - const isLockedResult = await rq.isLocked(); - expect(isLockedResult).to.be.false; - - await rq.destroy(); - }); - - it('should handle lock/unlock operations', async () => { - const rq: any = new RedisQueue(uuid(), { logger }); - await rq.start(); - - // Test locking - const lockResult = await rq.lock(); - expect(lockResult).to.be.a('boolean'); - - // Test checking if locked - const isLockedResult = await rq.isLocked(); - expect(isLockedResult).to.be.a('boolean'); - - // Test unlocking - const unlockResult = await rq.unlock(); - expect(unlockResult).to.be.a('boolean'); - - await rq.destroy(); - }); - }); - - describe('utility methods', () => { - it('should test isPublisher and isWorker methods', async () => { - const publisherQueue = new RedisQueue(uuid(), { logger }, IMQMode.PUBLISHER); - const workerQueue = new RedisQueue(uuid(), { logger }, IMQMode.WORKER); - - expect(publisherQueue.isPublisher()).to.be.true; - expect(publisherQueue.isWorker()).to.be.false; - - expect(workerQueue.isPublisher()).to.be.false; - expect(workerQueue.isWorker()).to.be.true; - - await workerQueue.destroy(); - await publisherQueue.destroy(); - }); - - it('should test key and lockKey methods', async () => { - const name = uuid(); - const rq: any = new RedisQueue(name, { logger }); - - expect(rq.key).to.be.a('string'); - expect(rq.key).to.include(name); - - expect(rq.lockKey).to.be.a('string'); - expect(rq.lockKey).to.include('watch:lock'); - - await rq.destroy(); - }); - }); -}); diff --git a/test/RedisQueue.unsubscribe.spec.ts b/test/RedisQueue.unsubscribe.spec.ts deleted file mode 100644 index a5af0e8..0000000 --- a/test/RedisQueue.unsubscribe.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -/*! - * Additional RedisQueue tests: unsubscribe() cleanup path - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { RedisQueue } from '../src'; - -function makeLogger() { - return { - log: (..._args: any[]) => undefined, - info: (..._args: any[]) => undefined, - warn: (..._args: any[]) => undefined, - error: (..._args: any[]) => undefined, - } as any; -} - -describe('RedisQueue.unsubscribe()', function() { - this.timeout(10000); - - it('should cleanup subscription channel when present', async () => { - const logger = makeLogger(); - const rq: any = new RedisQueue('SubUnsub', { logger }); - await rq.start(); - - const handler = sinon.spy(); - await rq.subscribe('SubUnsub', handler); - - expect(rq.subscription).to.be.ok; - expect(rq.subscriptionName).to.equal('SubUnsub'); - - const unsubSpy = sinon.spy(rq.subscription, 'unsubscribe'); - const ralSpy = sinon.spy(rq.subscription, 'removeAllListeners'); - const disconnectSpy = sinon.spy(rq.subscription, 'disconnect'); - const quitSpy = sinon.spy(rq.subscription, 'quit'); - - await rq.unsubscribe(); - - expect(unsubSpy.calledOnce).to.equal(true); - expect(ralSpy.calledOnce).to.equal(true); - expect(disconnectSpy.calledOnce).to.equal(true); - expect(quitSpy.calledOnce).to.equal(true); - expect(rq.subscription).to.equal(undefined); - expect(rq.subscriptionName).to.equal(undefined); - - unsubSpy.restore(); - ralSpy.restore(); - disconnectSpy.restore(); - quitSpy.restore(); - - await rq.destroy().catch(() => undefined); - }); -}); diff --git a/test/UDPClusterManager.destroySocket.spec.ts b/test/UDPClusterManager.destroySocket.spec.ts deleted file mode 100644 index 70fff96..0000000 --- a/test/UDPClusterManager.destroySocket.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -/*! - * UDPClusterManager.destroyWorker() behavior tests aligned with implementation - */ -import './mocks'; -import { expect } from 'chai'; -import { UDPClusterManager } from '../src'; - -describe('UDPClusterManager.destroyWorker()', () => { - it('should resolve when worker is undefined (no-op)', async () => { - const destroy = (UDPClusterManager as any).destroyWorker as Function; - await destroy('0.0.0.0:63000', undefined); - }); - - it('should terminate worker and remove it from the workers map', async () => { - const destroy = (UDPClusterManager as any).destroyWorker as Function; - const workers = (UDPClusterManager as any).workers as Record; - const key = '1.2.3.4:65000'; - - let terminated = false; - const fakeWorker: any = { - postMessage: () => {}, - once: (event: string, cb: Function) => { - if (event === 'message') { - setImmediate(() => cb({ type: 'stopped' })); - } - }, - terminate: () => { terminated = true; }, - }; - - workers[key] = fakeWorker; - await destroy(key, fakeWorker); - - expect(terminated).to.equal(true); - expect(workers[key]).to.be.undefined; - }); -}); diff --git a/test/UDPClusterManager.missing.branches.spec.ts b/test/UDPClusterManager.missing.branches.spec.ts deleted file mode 100644 index 5d4e892..0000000 --- a/test/UDPClusterManager.missing.branches.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -/*! - * UDPClusterManager missing branches coverage - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { UDPClusterManager } from '../src'; - -describe('UDPClusterManager - cover remaining branches', () => { - it('destroySocket should call socket.unref() when socket is present', async () => { - // Prepare fake socket with unref - const unref = sinon.spy(); - const removeAll = sinon.spy(); - const sock: any = { - removeAllListeners: removeAll, - close: (cb: (err?: any) => void) => cb(), - unref, - }; - const key = 'test-key'; - (UDPClusterManager as any).sockets[key] = sock; - await (UDPClusterManager as any).destroySocket(key, sock); - expect(unref.called).to.equal(true); - expect((UDPClusterManager as any).sockets[key]).to.equal(undefined); - }); - - it('destroySocket should work when socket.unref() is absent (optional chaining negative branch)', async () => { - const removeAll = sinon.spy(); - const sock: any = { - removeAllListeners: removeAll, - close: (cb: (err?: any) => void) => cb(), - // no unref method - }; - const key = 'test-key-2'; - (UDPClusterManager as any).sockets[key] = sock; - await (UDPClusterManager as any).destroySocket(key, sock); - // should not throw, sockets map cleaned - expect((UDPClusterManager as any).sockets[key]).to.equal(undefined); - }); -}); diff --git a/test/UDPClusterManager.ts b/test/UDPClusterManager.ts deleted file mode 100644 index 98268a1..0000000 --- a/test/UDPClusterManager.ts +++ /dev/null @@ -1,196 +0,0 @@ -/*! - * RedisQueue Unit Tests - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ -import './mocks'; -import { expect } from 'chai'; -import { UDPClusterManager } from '../src'; -import * as sinon from 'sinon'; - -const testMessageUp = { - name: 'IMQUnitTest', - id: '1234567890', - type: 'up', - address: '127.0.0.1:6379', - timeout: 50, -}; - -const testMessageDown = { - name: 'IMQUnitTest', - id: '1234567890', - type: 'down', - address: '127.0.0.1:6379', - timeout: 50, -}; - -const getSocket = (classObject: any) => { - return classObject.worker; -}; - -const emitMessage = ( - instanceClass: any, - type: 'cluster:add' | 'cluster:remove', -) => { - getSocket(instanceClass).emit('message', { - type, - server: type === 'cluster:add' ? testMessageUp : testMessageDown, - }); -}; - -describe('UDPBroadcastClusterManager', function() { - this.timeout(5000); - it('should be a class', () => { - expect(typeof UDPClusterManager).to.equal('function'); - }); - - it('should call add on cluster', async () => { - const cluster: any = { - add: () => {}, - remove: () => {}, - find: () => {}, - }; - const manager: any = new UDPClusterManager(); - - sinon.spy(cluster, 'add'); - - manager.init(cluster); - - emitMessage(manager, 'cluster:add'); - expect(cluster.add.called).to.be.true; - await manager.destroy(); - }); - - it('should not call add on cluster if server exists', async () => { - const cluster: any = { - add: () => {}, - remove: () => {}, - find: () => { - return {}; - }, - }; - const manager: any = new UDPClusterManager(); - - sinon.spy(cluster, 'add'); - - manager.init(cluster); - - emitMessage(manager, 'cluster:add'); - expect(cluster.add.called).to.be.false; - await manager.destroy(); - }); - - it('should call remove on cluster', async () => { - const cluster: any = { - add: () => {}, - remove: () => {}, - find: () => { - return {}; - }, - }; - const manager: any = new UDPClusterManager(); - - sinon.spy(cluster, 'remove'); - - manager.init(cluster); - - emitMessage(manager, 'cluster:remove'); - expect(cluster.remove.called).to.be.true; - await manager.destroy(); - }); - - it('should handle server timeout and removal', (done) => { - let addedServer: any = null; - const cluster: any = { - add: () => {}, - remove: async (server: any) => { - expect(server).to.equal(addedServer); - await manager.destroy(); - }, - find: (message: any) => { - if (!addedServer) { - addedServer = { - id: message.id, - timer: null, - timestamp: Date.now(), - timeout: 50, // Short timeout for test - }; - return addedServer; - } - return addedServer; - }, - }; - const manager: any = new UDPClusterManager(); - - manager.init(cluster); - - // Send up message to add server with short timeout - emitMessage(manager, 'cluster:add'); - - // Wait for timeout to trigger removal - setTimeout(async () => { - await manager.destroy(); - done(); - }, 1000); - }); - - it('should handle timeout when server no longer exists', async () => { - let serverAdded = false; - const cluster: any = { - add: () => {}, - remove: () => {}, - find: (message: any) => { - if (!serverAdded) { - serverAdded = true; - return { - id: message.id, - timer: null, - timestamp: Date.now(), - timeout: 50, - }; - } - // Return null to simulate server no longer existing - return null; - }, - }; - const manager: any = new UDPClusterManager(); - - manager.init(cluster); - - // This should trigger the timeout handler that returns early (line 307) - emitMessage(manager, 'cluster:add'); - await manager.destroy(); - }); - - describe('destroy()', () => { - it('should handle empty sockets gracefully', async () => { - const manager: any = new UDPClusterManager(); - - // Clear any existing sockets - (UDPClusterManager as any).sockets = {}; - - // Should not throw when no sockets exist - await manager.destroy(); - - expect(Object.keys((UDPClusterManager as any).sockets)).to.have.length(0); - }); - }); -}); diff --git a/test/helpers/index.ts b/test/helpers/index.ts new file mode 100644 index 0000000..941666d --- /dev/null +++ b/test/helpers/index.ts @@ -0,0 +1,24 @@ +/*! + * IMQ Unit Test Helpers + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +export * from './makeLogger'; diff --git a/test/helpers/makeLogger.ts b/test/helpers/makeLogger.ts new file mode 100644 index 0000000..bbbb5bb --- /dev/null +++ b/test/helpers/makeLogger.ts @@ -0,0 +1,38 @@ +/*! + * IMQ Unit Test Helpers + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { ILogger } from '../../src'; + +/** + * Builds a fresh no-op logger suitable for spying on in unit tests. + * + * @return {ILogger} + */ +export function makeLogger(): ILogger { + return { + log: (..._args: any[]) => undefined, + info: (..._args: any[]) => undefined, + warn: (..._args: any[]) => undefined, + error: (..._args: any[]) => undefined, + } as unknown as ILogger; +} diff --git a/test/mocks/dgram.ts b/test/mocks/dgram.ts index d238f01..207fdb1 100644 --- a/test/mocks/dgram.ts +++ b/test/mocks/dgram.ts @@ -21,11 +21,11 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import mock = require('mock-require'); -import { EventEmitter } from 'events'; +import { EventEmitter } from 'node:events'; +import { mockBuiltin } from './mockBuiltin'; class Socket extends EventEmitter { - public bindArgs: any[]; + public bindArgs: any[] = []; constructor() { super(); @@ -38,10 +38,16 @@ class Socket extends EventEmitter { return this; } + + public close(callback?: () => void): void { + if (callback) { + callback(); + } + } } export const createSocket = () => { return new Socket(); }; -mock('dgram', { createSocket }); +mockBuiltin('dgram', { createSocket }); diff --git a/test/mocks/index.ts b/test/mocks/index.ts index 21ece3e..9bb7bdf 100644 --- a/test/mocks/index.ts +++ b/test/mocks/index.ts @@ -21,6 +21,7 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ +export * from './mockBuiltin'; export * from './redis'; export * from './logger'; export * from './dgram'; diff --git a/test/mocks/logger.ts b/test/mocks/logger.ts index 2d7e4d2..df5afa6 100644 --- a/test/mocks/logger.ts +++ b/test/mocks/logger.ts @@ -25,5 +25,5 @@ export const logger: any = { log() {}, info() {}, warn() {}, - error() {} + error() {}, }; diff --git a/test/mocks/mockBuiltin.ts b/test/mocks/mockBuiltin.ts new file mode 100644 index 0000000..de16333 --- /dev/null +++ b/test/mocks/mockBuiltin.ts @@ -0,0 +1,42 @@ +/*! + * IMQ Unit Test Mocks: mockBuiltin helper + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import mock = require('mock-require'); + +/** + * Registers a mock for a Node.js built-in module under both its bare specifier + * (e.g. `os`) and its `node:`-prefixed form (e.g. `node:os`). This keeps the + * mock effective no matter which import form the code under test uses, so the + * two never drift out of sync. + * + * @param {string} name - the built-in module name, without the `node:` prefix + * @param {Parameters[1]} impl - the mock implementation to register + * @returns {void} + */ +export function mockBuiltin( + name: string, + impl: Parameters[1], +): void { + mock(name, impl); + mock(`node:${name}`, impl); +} diff --git a/test/mocks/os.ts b/test/mocks/os.ts index be7ee59..4360aff 100644 --- a/test/mocks/os.ts +++ b/test/mocks/os.ts @@ -19,8 +19,8 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import mock = require('mock-require'); -import * as os from 'node:os'; +import os from 'node:os'; +import { mockBuiltin } from './mockBuiltin'; export const networkInterfaces = () => { return { @@ -37,4 +37,4 @@ export const networkInterfaces = () => { }; }; -mock('os', Object.assign(os, { networkInterfaces })); +mockBuiltin('os', { ...os, networkInterfaces }); diff --git a/test/mocks/redis.ts b/test/mocks/redis.ts index d652392..05b54c1 100644 --- a/test/mocks/redis.ts +++ b/test/mocks/redis.ts @@ -21,12 +21,12 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import * as mock from 'mock-require'; -import { EventEmitter } from 'events'; -import * as crypto from 'crypto'; +import mock from 'mock-require'; +import { EventEmitter } from 'node:events'; +import { createHash, Hash } from 'node:crypto'; function sha1(str: string) { - let sha: crypto.Hash = crypto.createHash('sha1'); + let sha: Hash = createHash('sha1'); sha.update(str); return sha.digest('hex'); } @@ -41,12 +41,13 @@ export class RedisClientMock extends EventEmitter { private static __keys: any = {}; private static __scripts: any = {}; private __name: string = ''; - // noinspection JSUnusedGlobalSymbols public connected: boolean = true; public status = 'ready'; + public options: any; constructor(options: any = {}) { super(); + this.options = options; setTimeout(() => { this.emit('ready', this); }); @@ -57,9 +58,8 @@ export class RedisClientMock extends EventEmitter { } } - // noinspection JSUnusedGlobalSymbols public end() {} - // noinspection JSUnusedGlobalSymbols + public quit() { return new Promise(resolve => resolve(undefined)); } @@ -68,7 +68,6 @@ export class RedisClientMock extends EventEmitter { return new Promise(resolve => resolve(undefined)); } - // noinspection JSMethodCanBeStatic public set(...args: any[]): Promise { const [key, val] = args; RedisClientMock.__keys[key] = val; @@ -76,12 +75,11 @@ export class RedisClientMock extends EventEmitter { return new Promise(resolve => resolve(1)); } - // noinspection JSUnusedGlobalSymbols,JSMethodCanBeStatic public setnx(...args: any[]): number { const self = RedisClientMock; const key = args.shift(); let result = 0; - if (/:watch:lock$/.test(key)) { + if (String(key).endsWith(':watch:lock')) { if (typeof self.__keys[key] === 'undefined') { self.__keys[key] = args.shift(); result = 1; @@ -93,7 +91,6 @@ export class RedisClientMock extends EventEmitter { return result; } - // noinspection TypescriptExplicitMemberType,JSMethodCanBeStatic public lpush(key: string, value: any, cb?: any): number { const self = RedisClientMock; if (!self.__queues__[key]) { @@ -108,12 +105,15 @@ export class RedisClientMock extends EventEmitter { const [key, timeout, cb] = args; const q = RedisClientMock.__queues__[key] || []; if (!q.length) { - this.__rt && clearTimeout(this.__rt); + if (this.__rt) { + clearTimeout(this.__rt); + } return new Promise(resolve => { - this.__rt = setTimeout(() => resolve(this.brpop( - key, timeout, cb, - )), timeout || 100); + this.__rt = setTimeout( + () => resolve(this.brpop(key, timeout, cb)), + timeout || 100, + ); }); } else { const result = [key, q.shift()]; @@ -128,43 +128,107 @@ export class RedisClientMock extends EventEmitter { from: string, to: string, timeout: number, - cb?: Function + cb?: Function, ): Promise { - const fromQ = RedisClientMock.__queues__[from] = - RedisClientMock.__queues__[from] || []; - const toQ = RedisClientMock.__queues__[to] = - RedisClientMock.__queues__[to] || []; + const fromQ = (RedisClientMock.__queues__[from] = + RedisClientMock.__queues__[from] || []); + const toQ = (RedisClientMock.__queues__[to] = + RedisClientMock.__queues__[to] || []); if (!fromQ.length) { - this.__rt && clearTimeout(this.__rt); + if (this.__rt) { + clearTimeout(this.__rt); + } return new Promise(resolve => { - this.__rt = setTimeout(() => resolve(this.brpoplpush( - from, to, timeout, cb, - )), timeout || 100); + this.__rt = setTimeout( + () => resolve(this.brpoplpush(from, to, timeout, cb)), + timeout || 100, + ); }); } else { toQ.push(fromQ.shift()); - cb && cb(null, '1'); + if (cb) { + cb(null, '1'); + } return '1'; } } - // noinspection JSUnusedGlobalSymbols,JSMethodCanBeStatic + public async lmove( + from: string, + to: string, + _fromSide?: string, + _toSide?: string, + cb?: Function, + ): Promise { + const qs = RedisClientMock.__queues__; + const fromQ = (qs[from] = qs[from] || []); + + if (!fromQ.length) { + this.cbExecute(cb, null, null); + + return null; + } + + const element = fromQ.shift(); + + (qs[to] = qs[to] || []).push(element); + this.cbExecute(cb, null, element); + + return element; + } + + public async blmove( + from: string, + to: string, + _fromSide?: string, + _toSide?: string, + timeout?: number, + ): Promise { + const deadline = Date.now() + (Number(timeout) || 0.1) * 1000; + + while (true) { + const qs = RedisClientMock.__queues__; + const fromQ = (qs[from] = qs[from] || []); + + if (fromQ.length) { + const element = fromQ.shift(); + + (qs[to] = qs[to] || []).push(element); + + return element; + } + + if (Date.now() >= deadline) { + return null; + } + + await new Promise(resolve => { + this.__rt = setTimeout(resolve, 25); + }); + } + } + + public llen(key: string, cb?: Function): number { + const q = RedisClientMock.__queues__[key] || []; + this.cbExecute(cb, null, q.length); + return q.length; + } + public lrange( key: string, start: number, stop: number, cb?: Function, ): boolean { - const q = RedisClientMock.__queues__[key] = - RedisClientMock.__queues__[key] || []; + const q = (RedisClientMock.__queues__[key] = + RedisClientMock.__queues__[key] || []); const result = q.splice(start, stop); this.cbExecute(cb, null, result); return result; } - // noinspection JSUnusedGlobalSymbols,JSMethodCanBeStatic public scan(...args: any[]): (string | string[])[] { const cb = args.pop(); const qs = RedisClientMock.__queues__; @@ -179,7 +243,6 @@ export class RedisClientMock extends EventEmitter { return result; } - // noinspection JSMethodCanBeStatic public script(...args: any[]): unknown { const cmd = args.shift(); const scriptOrHash = args.shift(); @@ -189,13 +252,17 @@ export class RedisClientMock extends EventEmitter { if (cmd === 'LOAD') { const hash = sha1(scriptOrHash); RedisClientMock.__scripts[hash] = scriptOrHash; - isCb && cb(null, hash); + if (isCb) { + cb(null, hash); + } return hash; } if (cmd === 'EXISTS') { const hash = RedisClientMock.__scripts[scriptOrHash] !== undefined; - isCb && cb(null, hash); + if (isCb) { + cb(null, hash); + } return [Number(hash)]; } @@ -203,7 +270,6 @@ export class RedisClientMock extends EventEmitter { return [0]; } - // noinspection JSUnusedGlobalSymbols public client(...args: any[]): string | boolean { const self = RedisClientMock; const cmd = args.shift(); @@ -217,8 +283,7 @@ export class RedisClientMock extends EventEmitter { this.cbExecute(cb, null, result); return result; - } - else if (cmd === 'SETNAME') { + } else if (cmd === 'SETNAME') { this.__name = name; self.__clientList[name] = true; } @@ -227,7 +292,6 @@ export class RedisClientMock extends EventEmitter { return true; } - // noinspection JSMethodCanBeStatic public exists(...args: any[]): boolean { const key = args.shift(); const result = RedisClientMock.__keys[key] !== undefined; @@ -235,7 +299,6 @@ export class RedisClientMock extends EventEmitter { return result; } - // noinspection JSUnusedGlobalSymbols,JSMethodCanBeStatic public psubscribe(...args: any[]): Promise { this.cbExecute(args.pop(), null, 1); return new Promise(resolve => resolve(1)); @@ -246,13 +309,11 @@ export class RedisClientMock extends EventEmitter { return new Promise(resolve => resolve(1)); } - // noinspection JSUnusedGlobalSymbols,JSMethodCanBeStatic public evalsha(...args: any[]): boolean { this.cbExecute(args.pop()); return true; } - // noinspection JSUnusedGlobalSymbols,JSMethodCanBeStatic public del(...args: any[]): Promise { const self = RedisClientMock; let count = 0; @@ -270,19 +331,17 @@ export class RedisClientMock extends EventEmitter { return new Promise(resolve => resolve(count)); } - // noinspection JSUnusedGlobalSymbols public zadd(...args: any[]): boolean { const [key, score, value, cb] = args; const timeout = score - Date.now(); setTimeout(() => { - const toKey = key.split(/:/).slice(0,2).join(':'); + const toKey = key.split(/:/).slice(0, 2).join(':'); this.lpush(toKey, value); }, timeout); this.cbExecute(cb); return true; } - // noinspection JSUnusedGlobalSymbols public disconnect(): boolean { delete RedisClientMock.__clientList[this.__name]; if (this.__rt) { @@ -292,23 +351,19 @@ export class RedisClientMock extends EventEmitter { return true; } - // noinspection JSUnusedGlobalSymbols,JSMethodCanBeStatic public publish(channel: string, message: string, cb?: any): number { this.cbExecute(cb, null, 1); return 1; } - // noinspection JSUnusedGlobalSymbols,JSMethodCanBeStatic public subscribe(channel: string, cb?: any): void { this.cbExecute(cb, null, 1); } - // noinspection JSUnusedGlobalSymbols,JSMethodCanBeStatic public unsubscribe(channel?: string, cb?: any): void { this.cbExecute(cb, null, 1); } - // noinspection JSUnusedGlobalSymbols,JSMethodCanBeStatic public config(): Promise { return new Promise(resolve => resolve(true)); } @@ -321,6 +376,7 @@ export class RedisClientMock extends EventEmitter { } mock('ioredis', { + __esModule: true, default: RedisClientMock, Redis: RedisClientMock, }); diff --git a/test/profile.decorator.branches.spec.ts b/test/profile.decorator.branches.spec.ts deleted file mode 100644 index aa9c08a..0000000 --- a/test/profile.decorator.branches.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -/*! - * Additional tests to cover remaining branches in profile decorator - */ -import './mocks'; -import { expect } from 'chai'; -import { profile, LogLevel } from '..'; - -// Note: We intentionally call decorated methods without a "this" context -// to exercise the (this || target) branches inside the decorator wrapper. - -describe('profile decorator extra branches', () => { - it('should return early via original.apply(target, ...) when both debug flags are false and this is undefined', () => { - class T1 { - @profile({ enableDebugTime: false, enableDebugArgs: false, logLevel: LogLevel.LOG }) - public m(...args: any[]) { return args; } - } - const o = new T1(); - const fn = Object.getPrototypeOf(o).m as Function; // wrapper - const res = fn.call(undefined, 1, 2, 3); - expect(res).to.deep.equal([1, 2, 3]); - }); - - it('should execute debug path with (this || target) picking target and logLevel fallback to IMQ_LOG_LEVEL', () => { - class T2 { - // no logger on prototype; calling with undefined this picks target - @profile({ enableDebugTime: true, enableDebugArgs: true, logLevel: undefined as any }) - public m(..._args: any[]) { /* noop */ } - } - const o = new T2(); - const fn = Object.getPrototypeOf(o).m as Function; // wrapper - // provide serializable args to avoid logger.error path when logger is undefined - expect(() => fn.call(undefined, 1, { a: 2 }, 'x')).to.not.throw(); - }); -}); diff --git a/test/profile.more.branches.spec.ts b/test/profile.more.branches.spec.ts deleted file mode 100644 index 80f1672..0000000 --- a/test/profile.more.branches.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -/*! - * Additional profile.ts branch coverage tests - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import * as mock from 'mock-require'; - -// We re-require the module inside tests to pick up env changes when needed - -describe('profile.ts additional branches', () => { - afterEach(() => { - mock.stopAll(); - delete (process as any).env.IMQ_LOG_TIME_FORMAT; - }); - - it('logDebugInfo: should not attempt to call missing log method (no-op path)', () => { - const { logDebugInfo, LogLevel } = mock.reRequire('../src/profile'); - const fakeLogger: any = { - // intentionally no 'log' or 'info' method for selected level - error: sinon.spy(), - }; - const options = { - debugTime: true, - debugArgs: true, - className: 'X', - args: [1, { a: 2 }], - methodName: 'm', - start: (process.hrtime as any).bigint(), - logger: fakeLogger, - logLevel: LogLevel.LOG, - }; - expect(() => logDebugInfo(options)).to.not.throw(); - // ensures error not called due to serialization success - expect(fakeLogger.error.called).to.equal(false); - }); - - it('logDebugInfo: should call logger.error on JSON.stringify error (BigInt arg)', () => { - const { logDebugInfo, LogLevel } = mock.reRequire('../src/profile'); - const fakeLogger: any = { - error: sinon.spy(), - }; - const args = [BigInt(1)]; // JSON.stringify throws on BigInt - const options = { - debugTime: false, - debugArgs: true, - className: 'Y', - args, - methodName: 'n', - start: (process.hrtime as any).bigint(), - logger: fakeLogger, - logLevel: LogLevel.INFO, - }; - logDebugInfo(options); - expect(fakeLogger.error.calledOnce).to.equal(true); - }); -}); diff --git a/test/profile.rejection.spec.ts b/test/profile.rejection.spec.ts deleted file mode 100644 index bc4b7f0..0000000 --- a/test/profile.rejection.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -/*! - * Additional profile tests for async rejection catch path - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { profile } from '..'; -import * as core from '..'; - -class RejectingClass { - public logger: any = { info: () => undefined, error: () => undefined }; - - @profile({ enableDebugTime: true, enableDebugArgs: true }) - public async willReject(): Promise { - return Promise.reject(new Error('boom')); - } -} - -describe('profile() async rejection path', () => { - it('should log via logger when async method rejects', async () => { - const logger = { info: sinon.spy(), error: () => undefined } as any; - const obj = new RejectingClass(); - obj.logger = logger; - try { - await obj.willReject(); - } catch (e) { - // expected - } - // allow microtask queue - await new Promise(res => setTimeout(res, 0)); - expect(logger.info.called).to.be.true; - }); -}); diff --git a/test/profile.ts b/test/profile.ts deleted file mode 100644 index 8ad7433..0000000 --- a/test/profile.ts +++ /dev/null @@ -1,219 +0,0 @@ -/*! - * profile() Function Unit Tests - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import * as mock from 'mock-require'; -import { - profile, - ILogger, - verifyLogLevel, - LogLevel, - DebugInfoOptions, -} from '..'; -import { logger } from './mocks'; - -const BIG_INT_SUPPORT = (() => { - try { - return !!BigInt(0); - } catch (err) { - return false; - } -})(); - -class ProfiledClass { - private logger: ILogger = logger; - - @profile() - public decoratedMethod(...args: any[]) { - return args; - } - - @profile({ - enableDebugTime: true, - enableDebugArgs: true, - logLevel: LogLevel.LOG, - }) - public async decoratedAsyncMethod() { - return new Promise(resolve => setTimeout(resolve, 10)); - } -} - -class ProfiledClassTimed { - private logger: ILogger = logger; - - @profile({ - enableDebugTime: true, - logLevel: LogLevel.LOG, - }) - public decoratedMethod() {} -} - -class ProfiledClassArgued { - private logger: ILogger = logger; - - @profile({ - enableDebugTime: false, - enableDebugArgs: true, - logLevel: LogLevel.LOG, - }) - public decoratedMethod() {} -} - -class ProfiledClassTimedAndArgued { - private logger: ILogger = logger; - - @profile({ - enableDebugTime: true, - enableDebugArgs: true, - logLevel: LogLevel.LOG, - }) - public decoratedMethod() {} -} - -describe('profile()', function() { - let log: any; - let error: any; - let warn: any; - let info: any; - - beforeEach(() => { - log = sinon.spy(logger, 'log'); - error = sinon.spy(logger, 'error'); - warn = sinon.spy(logger, 'warn'); - info = sinon.spy(logger, 'info'); - }); - - afterEach(() => { - log.restore(); - error.restore(); - warn.restore(); - info.restore(); - mock.stopAll(); - delete process.env.IMQ_LOG_TIME_FORMAT; - }); - - it('should be a function', () => { - expect(typeof profile).to.equal('function'); - }); - - it('should be decorator factory', () => { - expect(typeof profile()).to.equal('function'); - }); - - it('should pass decorated method args with no change', () => { - expect(new ProfiledClass().decoratedMethod(1, 2, 3)) - .to.deep.equal([1, 2, 3]); - }); - - it('should log time if enabled', () => { - new ProfiledClassTimed().decoratedMethod(); - expect(log.calledOnce).to.be.true; - }); - - it('should log args if enabled', () => { - new ProfiledClassArgued().decoratedMethod(); - expect(log.calledOnce).to.be.true; - }); - - it('should log time and args if both enabled', () => { - new ProfiledClassTimedAndArgued().decoratedMethod(); - expect(log.calledTwice).to.be.true; - }); - - it('should handle async methods correctly', async () => { - await new ProfiledClass().decoratedAsyncMethod(); - expect(log.calledTwice).to.be.true; - }); - - describe('verifyLogLevel()', () => { - it('should return valid log levels as is', () => { - expect(verifyLogLevel(LogLevel.LOG)).to.equal(LogLevel.LOG); - expect(verifyLogLevel(LogLevel.INFO)).to.equal(LogLevel.INFO); - expect(verifyLogLevel(LogLevel.WARN)).to.equal(LogLevel.WARN); - expect(verifyLogLevel(LogLevel.ERROR)).to.equal(LogLevel.ERROR); - }); - - it('should return default log level on invalid value', () => { - expect(verifyLogLevel('invalid')).to.equal(LogLevel.INFO); - }); - }); - - describe('logDebugInfo()', () => { - const start = BIG_INT_SUPPORT ? BigInt(1) : 1; - const baseOptions: DebugInfoOptions = { - debugTime: true, - debugArgs: true, - className: 'TestClass', - args: [1, 'a', { b: 2 }], - methodName: 'testMethod', - start, - logger, - logLevel: LogLevel.LOG, - }; - - it('should log time in microseconds by default', () => { - const { logDebugInfo } = mock.reRequire('../src/profile'); - logDebugInfo(baseOptions); - expect(log.calledWithMatch(/μs/)).to.be.true; - }); - - it('should log time in milliseconds', () => { - process.env.IMQ_LOG_TIME_FORMAT = 'milliseconds'; - const { logDebugInfo } = mock.reRequire('../src/profile'); - logDebugInfo(baseOptions); - expect(log.calledWithMatch(/ms/)).to.be.true; - }); - - it('should log time in seconds', () => { - process.env.IMQ_LOG_TIME_FORMAT = 'seconds'; - const { logDebugInfo } = mock.reRequire('../src/profile'); - logDebugInfo(baseOptions); - expect(log.calledWithMatch(/sec/)).to.be.true; - }); - - it('should handle circular references in args', () => { - const { logDebugInfo } = mock.reRequire('../src/profile'); - const a: any = { b: 1 }; - const b = { a }; - a.b = b; - logDebugInfo({ ...baseOptions, args: [a] }); - expect(error.notCalled).to.be.true; - }); - - it('should not log when logger method is missing', () => { - const { logDebugInfo } = mock.reRequire('../src/profile'); - const dummyLogger: any = { error: logger.error.bind(logger) }; - logDebugInfo({ ...baseOptions, logger: dummyLogger, logLevel: 'nonexistent' as any }); - expect(log.notCalled).to.be.true; - }); - - it('should handle JSON.stringify errors', () => { - const { logDebugInfo } = mock.reRequire('../src/profile'); - const badJson = { toJSON: () => { throw new Error('bad json'); } }; - logDebugInfo({ ...baseOptions, args: [badJson] }); - expect(error.calledOnce).to.be.true; - }); - }); -}); \ No newline at end of file diff --git a/test/promisify.ts b/test/promisify.ts deleted file mode 100644 index 86eea56..0000000 --- a/test/promisify.ts +++ /dev/null @@ -1,94 +0,0 @@ -/*! - * promisify() Function Unit Tests - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ -import './mocks'; -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import { promisify } from '..'; - -class FirstTestClass { - public one(callback?: (...args: any[]) => void) { - setTimeout(() => callback && callback()); - } -} - -class SecondTestClass { - public one(callback?: (...args: any[]) => void) { - setTimeout(() => callback && callback()); - } - public two(callback?: (...args: any[]) => void) { - setTimeout(() => callback && callback()); - } - public three(callback?: (...args: any[]) => void) { - setTimeout(() => callback && callback()); - } -} - -class ThirdTestClass { - // noinspection JSMethodCanBeStatic - public one(callback?: (...args: any[]) => void) { - callback && callback(); - } -} - -class TestError extends Error {} -class FourthTestClass { - public one(callback?: (...args: any[]) => void) { - setTimeout(() => callback && callback(new TestError())); - } -} - -describe('promisify()', function() { - - it('should convert callback-based method to promise-like', async () => { - promisify(FirstTestClass.prototype); - const o = new FirstTestClass(); - expect(o.one()).to.be.instanceof(Promise); - }); - - it('should restrict conversion to a given list of methods only', async () => { - promisify(SecondTestClass.prototype, ['one', 'three']); - const o = new SecondTestClass(); - expect(o.one()).to.be.instanceof(Promise); - expect(o.two()).to.be.undefined; - expect(o.three()).to.be.instanceof(Promise); - }); - - it('should act as callback-based if callback bypassed', () => { - promisify(ThirdTestClass.prototype); - const o = new ThirdTestClass(); - const spy = sinon.spy(() => {}); - expect(o.one(spy)).to.be.undefined; - expect(spy.called).to.be.true; - }); - - it('should throw when promise-like', async () => { - promisify(FourthTestClass.prototype); - const o = new FourthTestClass(); - try { - await o.one(); - } catch (err) { - expect(err).to.be.instanceof(TestError); - } - }); -}); diff --git a/test/unit/ClusterManager.spec.ts b/test/unit/ClusterManager.spec.ts new file mode 100644 index 0000000..e3f96c1 --- /dev/null +++ b/test/unit/ClusterManager.spec.ts @@ -0,0 +1,103 @@ +/*! + * ClusterManager additional tests + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + */ +import '../mocks'; +import { describe, it, afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { ClusterManager, InitializedCluster } from '../../src/ClusterManager'; + +class TestClusterManager extends ClusterManager { + public destroyed = false; + public constructor() { + super(); + } + public async destroy(): Promise { + this.destroyed = true; + } +} + +describe('ClusterManager.remove()', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('should call destroy when the last cluster is removed and destroy=true', async () => { + const cm = new TestClusterManager(); + const cluster: InitializedCluster = cm.init({ + add: () => ({}) as any, + remove: () => undefined, + find: () => undefined, + }); + + // sanity: one cluster registered + assert.equal((cm as any).clusters.length, 1); + const spy = mock.method(cm, 'destroy'); + + await cm.remove(cluster, true); + + assert.equal(spy.mock.callCount(), 1); + assert.equal((cm as any).clusters.length, 0); + assert.equal(cm.destroyed, true); + }); + + it('should not call destroy when destroy=false', async () => { + const cm = new TestClusterManager(); + const cluster: InitializedCluster = cm.init({ + add: () => ({}) as any, + remove: () => undefined, + find: () => undefined, + }); + + const spy = mock.method(cm, 'destroy'); + await cm.remove(cluster.id, false); + + assert.equal(spy.mock.callCount() > 0, false); + assert.equal((cm as any).clusters.length, 0); + }); +}); + +describe('ClusterManager.forEachCluster()', () => { + const makeCluster = () => ({ + add: () => ({}) as any, + remove: () => undefined, + find: () => undefined, + }); + + it('should apply the callback to every registered cluster', async () => { + const cm = new TestClusterManager(); + const one = cm.init(makeCluster()); + const two = cm.init(makeCluster()); + const seen: string[] = []; + + await cm.forEachCluster(cluster => { + seen.push(cluster.id); + }); + + assert.deepEqual(seen.sort(), [one.id, two.id].sort()); + }); + + it('should process remaining clusters when one callback throws', async () => { + const cm = new TestClusterManager(); + + cm.init(makeCluster()); + cm.init(makeCluster()); + cm.init(makeCluster()); + + let calls = 0; + + // a synchronous throw on the first cluster must not prevent the + // remaining clusters from being processed, nor reject the call + await cm.forEachCluster(() => { + calls++; + + if (calls === 1) { + throw new Error('first cluster callback failed'); + } + }); + + assert.equal(calls, 3); + }); +}); diff --git a/test/unit/ClusteredRedisQueue.spec.ts b/test/unit/ClusteredRedisQueue.spec.ts new file mode 100644 index 0000000..8266318 --- /dev/null +++ b/test/unit/ClusteredRedisQueue.spec.ts @@ -0,0 +1,750 @@ +/*! + * ClusteredRedisQueue Unit Tests (core behavior + EventEmitter proxy methods, + * addServerWithQueueInitializing, initializeQueue, and matchServers) + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { logger } from '../mocks'; +import { describe, it, afterEach, mock, Mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { ClusteredRedisQueue, RedisQueue } from '../../src'; +import { ClusterManager } from '../../src/ClusterManager'; + +process.setMaxListeners(100); + +function assertDeepInclude(actual: any, subset: any): void { + for (const key of Object.keys(subset)) { + assert.deepEqual(actual[key], subset[key]); + } +} + +const clusterConfig = { + logger, + cluster: [ + { + host: '127.0.0.1', + port: 7777, + }, + { + host: '127.0.0.1', + port: 8888, + }, + ], +}; + +const server = { host: '127.0.0.1', port: 6380 }; + +// Access private static via casting +const match = (ClusteredRedisQueue as any).matchServers as ( + source: any, + target: any, + strict?: boolean, +) => boolean; + +describe('ClusteredRedisQueue', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('should be a class', () => { + assert.equal(typeof ClusteredRedisQueue, 'function'); + }); + + it('should implement IMessageQueue interface', () => { + assert.equal(typeof ClusteredRedisQueue.prototype.start, 'function'); + assert.equal(typeof ClusteredRedisQueue.prototype.stop, 'function'); + assert.equal(typeof ClusteredRedisQueue.prototype.send, 'function'); + assert.equal(typeof ClusteredRedisQueue.prototype.destroy, 'function'); + }); + + describe('constructor()', () => { + it('should throw with improper options passed', () => { + assert.throws( + () => new ClusteredRedisQueue('TestClusteredQueue'), + TypeError, + ); + }); + + it('should not throw if proper options passed', () => { + assert.doesNotThrow( + () => + new ClusteredRedisQueue( + 'TestClusteredQueue', + clusterConfig, + ), + ); + }); + + it('should initialize cluster manager', () => { + const clusterManager = new (ClusterManager as any)(); + + const init: Mock = mock.method(clusterManager, 'init'); + + new ClusteredRedisQueue('TestClusteredQueue', { + clusterManagers: [clusterManager], + }); + + assert.equal(init.mock.callCount() > 0, true); + }); + }); + + describe('start()', () => { + it('should start each nested imq', async () => { + const cq: any = new ClusteredRedisQueue( + 'TestClusteredQueue', + clusterConfig, + ); + + cq.imqs.forEach((imq: any) => { + mock.method(imq, 'start'); + }); + + await cq.start(); + + cq.imqs.forEach((imq: any) => { + assert.equal(imq.start.mock.callCount() > 0, true); + }); + + await cq.destroy(); + }); + }); + + describe('stop()', () => { + it('should stop each nested imq', async () => { + const cq: any = new ClusteredRedisQueue( + 'TestClusteredQueue', + clusterConfig, + ); + + cq.imqs.forEach((imq: any) => { + mock.method(imq, 'stop'); + }); + + await cq.stop(); + + cq.imqs.forEach((imq: any) => { + assert.equal(imq.stop.mock.callCount() > 0, true); + }); + + await cq.destroy(); + }); + }); + + describe('send()', () => { + it( + 'should balance send requests round-robin manner across nested ' + + 'queues', + async () => { + const cq: any = new ClusteredRedisQueue( + 'TestClusteredQueue', + clusterConfig, + ); + + cq.imqs.forEach((imq: any) => { + mock.method(imq, 'send'); + }); + + await cq.send('TestClusteredQueue', { hello: 'world' }); + + assert.equal(cq.imqs[0].send.mock.callCount(), 1); + assert.equal(cq.imqs[1].send.mock.callCount() > 0, false); + + await cq.send('TestClusteredQueue', { hello: 'world' }); + + assert.equal(cq.imqs[0].send.mock.callCount(), 1); + assert.equal(cq.imqs[1].send.mock.callCount(), 1); + + await cq.send('TestClusteredQueue', { hello: 'world' }); + + assert.equal(cq.imqs[0].send.mock.callCount(), 2); + assert.equal(cq.imqs[1].send.mock.callCount(), 1); + + await cq.destroy(); + }, + ); + + it('should send message after queue was initialized', () => { + return new Promise(resolve => { + const clusterManager = new (ClusterManager as any)(); + const cqOne: any = new ClusteredRedisQueue( + 'TestClusteredQueueOne', + { + clusterManagers: [clusterManager], + logger, + }, + ); + const cqTwo: any = new ClusteredRedisQueue( + 'TestClusteredQueueTwo', + { + clusterManagers: [clusterManager], + logger, + }, + ); + const message = { hello: 'world' }; + + cqOne.start(); + cqTwo.start(); + + cqTwo.on('message', () => { + cqOne.destroy(); + cqTwo.destroy(); + + resolve(); + }); + + cqOne.send('TestClusteredQueueTwo', message); + cqTwo.addServer(clusterConfig.cluster[0]); + cqOne.addServer(clusterConfig.cluster[0]); + }); + }); + }); + + describe('destroy()', () => { + it('should destroy each nested imq', async () => { + const cq: any = new ClusteredRedisQueue( + 'TestClusteredQueue', + clusterConfig, + ); + + cq.imqs.forEach((imq: any) => { + mock.method(imq, 'destroy'); + }); + + await cq.destroy(); + + cq.imqs.forEach((imq: any) => { + assert.equal(imq.destroy.mock.callCount() > 0, true); + }); + }); + }); + + describe('clear()', () => { + it('should clear each nested imq', async () => { + const cq: any = new ClusteredRedisQueue( + 'TestClusteredQueue', + clusterConfig, + ); + + cq.imqs.forEach((imq: any) => { + mock.method(imq, 'clear'); + }); + + await cq.clear(); + + cq.imqs.forEach((imq: any) => { + assert.equal(imq.clear.mock.callCount() > 0, true); + }); + + await cq.destroy(); + }); + }); + + describe('subscribe()', () => { + it('should subscribe after queue initialization', () => { + const clusterManager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('TestClusteredQueue', { + clusterManagers: [clusterManager], + logger, + }); + const channel = 'TestChannel'; + + cq.subscribe(channel, () => {}); + cq.addServer(clusterConfig.cluster[0]); + + assert.equal(cq.imqs[0].subscriptionName, channel); + }); + }); + + describe('addServer()', () => { + it('should add cluster server', () => { + const clusterManager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('TestClusteredQueue', { + clusterManagers: [clusterManager], + }); + + cq.addServer(clusterConfig.cluster[0]); + + assert.equal(cq.servers.length, 1); + }); + + it( + 'should call adding cluster server method through the' + + ' Cluster Manager', + () => { + const clusterManager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('TestClusteredQueue', { + clusterManagers: [clusterManager], + }); + + for (const server of clusterManager.clusters) { + server.add(clusterConfig.cluster[0]); + } + + assert.equal(cq.servers.length, 1); + }, + ); + }); + + describe('removeServer()', () => { + it('should remove cluster server', () => { + const clusterManager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('TestClusteredQueue', { + clusterManagers: [clusterManager], + }); + + cq.addServer(clusterConfig.cluster[0]); + cq.removeServer(clusterConfig.cluster[0]); + + assert.equal(cq.servers.length, 0); + }); + + it( + 'should call removing cluster server method through the' + + ' Cluster Manager', + () => { + const clusterManager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('TestClusteredQueue', { + clusterManagers: [clusterManager], + }); + + for (const server of clusterManager.clusters) { + server.remove(clusterConfig.cluster[0]); + } + + assert.equal(cq.servers.length, 0); + }, + ); + }); + + describe('findServer()', () => { + it('should find cluster server', () => { + const clusterManager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('TestClusteredQueue', { + clusterManagers: [clusterManager], + }); + + cq.addServer(clusterConfig.cluster[0]); + + const server = cq.findServer(clusterConfig.cluster[0]); + + assertDeepInclude(server, clusterConfig.cluster[0]); + }); + + it( + 'should call find cluster server method through the' + + ' Cluster Manager', + () => { + const clusterManager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('TestClusteredQueue', { + clusterManagers: [clusterManager], + }); + + cq.addServer(clusterConfig.cluster[0]); + + for (const cluster of clusterManager.clusters) { + const server = cluster.find(clusterConfig.cluster[0]); + + assertDeepInclude(server, clusterConfig.cluster[0]); + } + }, + ); + }); +}); + +describe('ClusteredRedisQueue - EventEmitter proxy methods', () => { + const clusterConfig = { + cluster: [{ host: '127.0.0.1', port: 6379 }], + }; + + it('should cover rawListeners/getMaxListeners/eventNames/listenerCount/emit', async () => { + const clusterManager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('ProxyQueue', { + clusterManagers: [clusterManager], + }); + + // add underlying server and listener + cq.addServer(clusterConfig.cluster[0]); + const handler: Mock = mock.fn(); + cq.imqs[0].on('test', handler); + + // set max listeners across emitters and verify getMaxListeners uses templateEmitter + cq.setMaxListeners(20); + assert.equal(cq.getMaxListeners(), 20); + + // collect raw listeners + const raw = cq.rawListeners('test'); + assert.ok(raw.length > 0); + + // event names come from underlying imq + const names = cq.eventNames(); + assert.ok(Array.isArray(names)); + assert.ok(names.map(String).includes('test')); + + // listener count is aggregated via templateEmitter method applied on imq[0] + assert.equal(cq.listenerCount('test'), 1); + + // emit should return true + assert.equal(cq.emit('test', 1, 2, 3), true); + assert.equal(handler.mock.callCount(), 1); + }); + + it('should forward listener registration and removal to every queue', async () => { + const cq: any = new ClusteredRedisQueue('ProxyFanout', { + cluster: [ + { host: '127.0.0.1', port: 6379 }, + { host: '127.0.0.1', port: 6380 }, + ], + }); + + assert.equal(cq.imqs.length, 2); + + const handler: Mock = mock.fn(); + + // on() attaches the handler to each underlying queue + cq.on('evt', handler); + + for (const imq of cq.imqs) { + assert.equal(imq.listenerCount('evt'), 1); + } + + // emit() reaches exactly the emitters where the listener is registered + const fanout = cq.listeners('evt').length; + cq.emit('evt', 'payload'); + assert.equal(handler.mock.callCount(), fanout); + + // addListener() attaches a second event on each queue + cq.addListener('evt2', handler); + + for (const imq of cq.imqs) { + assert.equal(imq.listenerCount('evt2'), 1); + } + + // removeAllListeners() clears only the targeted event everywhere + cq.removeAllListeners('evt'); + + for (const imq of cq.imqs) { + assert.equal(imq.listenerCount('evt'), 0); + assert.equal(imq.listenerCount('evt2'), 1); + } + + // removeListener()/off() detach from each queue + cq.removeListener('evt2', handler); + + for (const imq of cq.imqs) { + assert.equal(imq.listenerCount('evt2'), 0); + } + + assert.equal(cq.listeners('evt2').length, 0); + + await cq.destroy(); + }); + + it('should forward once/prepend variants with correct semantics', async () => { + const cq: any = new ClusteredRedisQueue('ProxyOnce', { + cluster: [{ host: '127.0.0.1', port: 6379 }], + }); + const imq = cq.imqs[0]; + + // once() is forwarded and auto-removed after a single emit + cq.once('evt', mock.fn()); + assert.equal(imq.listenerCount('evt'), 1); + cq.emit('evt'); + assert.equal(imq.listenerCount('evt'), 0); + + // prependListener() is forwarded and persists across emits + cq.prependListener('evt', mock.fn()); + assert.equal(imq.listenerCount('evt'), 1); + cq.emit('evt'); + cq.emit('evt'); + assert.equal(imq.listenerCount('evt'), 1); + + // prependOnceListener() is forwarded and auto-removed after one emit + cq.prependOnceListener('evt2', mock.fn()); + assert.equal(imq.listenerCount('evt2'), 1); + cq.emit('evt2'); + assert.equal(imq.listenerCount('evt2'), 0); + + // removeAllListeners() with no event clears every event everywhere + cq.removeAllListeners(); + assert.equal(imq.listenerCount('evt'), 0); + + await cq.destroy(); + }); +}); + +describe('ClusteredRedisQueue.addServerWithQueueInitializing() default param', () => { + it('should use default initializeQueue=true when second param omitted', async () => { + const cq: any = new ClusteredRedisQueue('CQ-Default', { + logger: console, + cluster: [{ host: '127.0.0.1', port: 6379 }], + }); + // prevent any actual start/subscription side-effects + (cq as any).state.started = false; + (cq as any).state.subscription = null; + + const server = { host: '192.168.0.1', port: 6380 }; + const initializedSpy = new Promise(resolve => { + cq['clusterEmitter'].once('initialized', () => resolve()); + }); + + // Call without the second argument to hit default "true" branch + (cq as any).addServerWithQueueInitializing(server); + + await initializedSpy; // should emit initialized when default is true + + // Ensure the server added and queue length updated + assert.equal( + (cq as any).servers.some( + (s: any) => s.host === server.host && s.port === server.port, + ), + true, + ); + assert.equal((cq as any).imqLength, (cq as any).imqs.length); + + await cq.destroy(); + }); +}); + +describe('ClusteredRedisQueue.addServerWithQueueInitializing(false)', () => { + it('should add server without initializing queue and not emit initialized', async () => { + const manager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('NoInit', { + clusterManagers: [manager], + }); + + let initializedCalled = false; + (cq as any).clusterEmitter.on('initialized', () => { + initializedCalled = true; + }); + + // call private method via any to cover branch + (cq as any).addServerWithQueueInitializing(server, false); + + // should have server and imq added + assert.ok(cq.servers.length > 0); + assert.ok(cq.imqs.length > 0); + // queueLength updated + assert.equal(cq.imqLength, cq.imqs.length); + // initialized not emitted + assert.equal(initializedCalled, false); + + await cq.destroy(); + }); +}); + +describe('ClusteredRedisQueue.initializeQueue()', () => { + it('should call imq.start when started and imq.subscribe when subscription is set', async () => { + const startStub: Mock = mock.method( + RedisQueue.prototype as any, + 'start', + async () => undefined, + ); + const subscribeStub: Mock = mock.method( + RedisQueue.prototype as any, + 'subscribe', + async () => undefined, + ); + + const clusterManager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('InitCover', { + clusterManagers: [clusterManager], + }); + + // mark started and set subscription using public APIs + await cq.start(); + const channel = 'X'; + const handler = () => undefined; + await cq.subscribe(channel, handler); + + // adding a server triggers initializeQueue which should call start and subscribe + cq.addServer({ host: '127.0.0.1', port: 6453 }); + + // allow promises to resolve + await new Promise(res => setTimeout(res, 0)); + + assert.ok(startStub.mock.callCount() > 0); + assert.ok(subscribeStub.mock.callCount() > 0); + + mock.restoreAll(); + await cq.destroy(); + }); +}); + +describe('ClusteredRedisQueue.matchServers()', () => { + it('should return sameAddress when no ids provided', () => { + assert.equal( + match({ host: 'h', port: 1 }, { host: 'h', port: 1 }), + true, + ); + assert.equal( + match({ host: 'h', port: 1 }, { host: 'h', port: 2 }), + false, + ); + }); + + it('should match servers if id provided', () => { + assert.equal( + match( + { id: 'a', host: 'h', port: 1 }, + { id: 'a', host: 'h', port: 2 }, + ), + true, + ); + assert.equal( + match( + { id: 'a', host: 'h', port: 1 }, + { id: 'b', host: 'h', port: 1 }, + ), + true, + ); + }); +}); + +describe('ClusteredRedisQueue fan-out helpers', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('selectQueue falls back to the start queue when none available', async () => { + const cq: any = new ClusteredRedisQueue('CQ-Fallback', clusterConfig); + + cq.imqs.forEach((imq: any) => { + Object.defineProperty(imq, 'available', { + get: () => false, + configurable: true, + }); + mock.method(imq, 'send', async () => 'id'); + }); + + await cq.send('CQ-Fallback', { a: 1 }); + + assert.equal(cq.imqs[0].send.mock.callCount(), 1); + + await cq.destroy(); + }); + + it('rejects send when no server becomes available in time', async () => { + const clusterManager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('CQ-Timeout', { + clusterManagers: [clusterManager], + logger, + }); + + cq.sendInitTimeout = 20; + + await assert.rejects( + cq.send('CQ-Timeout', { a: 1 }), + /no cluster server became available/, + ); + + await cq.destroy(); + }); + + it('queueLength() sums lengths across all queues', async () => { + const cq: any = new ClusteredRedisQueue('CQ-Len', clusterConfig); + + mock.method(cq.imqs[0], 'queueLength', async () => 3); + mock.method(cq.imqs[1], 'queueLength', async () => 4); + + assert.equal(await cq.queueLength(), 7); + + await cq.destroy(); + }); + + it('logs through verbose() when the verbose option is enabled', async () => { + const info: Mock = mock.method(logger, 'info'); + const clusterManager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('CQ-Verbose', { + clusterManagers: [clusterManager], + logger, + verbose: true, + }); + + assert.ok(info.mock.callCount() > 0); + + await cq.destroy(); + }); + + it('off() detaches a listener from every queue', async () => { + const cq: any = new ClusteredRedisQueue('CQ-Off', clusterConfig); + const handler: Mock = mock.fn(); + + cq.on('evt', handler); + cq.off('evt', handler); + + for (const imq of cq.imqs) { + assert.equal(imq.listenerCount('evt'), 0); + } + + await cq.destroy(); + }); + + it('publish() forwards to every queue', async () => { + const cq: any = new ClusteredRedisQueue('CQ-Pub', clusterConfig); + + cq.imqs.forEach((imq: any) => + mock.method(imq, 'publish', async () => undefined), + ); + + await cq.publish({ a: 1 }, 'target'); + + for (const imq of cq.imqs) { + assert.equal(imq.publish.mock.callCount(), 1); + } + + await cq.destroy(); + }); + + it('subscribe()/unsubscribe() forward to every queue', async () => { + const cq: any = new ClusteredRedisQueue('CQ-Sub', clusterConfig); + + cq.imqs.forEach((imq: any) => { + mock.method(imq, 'subscribe', async () => undefined); + mock.method(imq, 'unsubscribe', async () => undefined); + }); + + await cq.subscribe('chan', () => undefined); + await cq.unsubscribe(); + + for (const imq of cq.imqs) { + assert.equal(imq.subscribe.mock.callCount(), 1); + assert.equal(imq.unsubscribe.mock.callCount(), 1); + } + + await cq.destroy(); + }); + + it('returns the existing server when adding a duplicate', async () => { + const manager = new (ClusterManager as any)(); + const cq: any = new ClusteredRedisQueue('CQ-Existing', { + clusterManagers: [manager], + }); + + const first = cq.addServerWithQueueInitializing(server, false); + const second = cq.addServerWithQueueInitializing(server, false); + + assert.equal(second.host, first.host); + assert.equal(second.port, first.port); + assert.equal(cq.servers.length, 1); + + await cq.destroy(); + }); +}); diff --git a/test/IMessageQueue.EventEmitter.spec.ts b/test/unit/IMessageQueue.spec.ts similarity index 73% rename from test/IMessageQueue.EventEmitter.spec.ts rename to test/unit/IMessageQueue.spec.ts index b1fe92f..b592ecd 100644 --- a/test/IMessageQueue.EventEmitter.spec.ts +++ b/test/unit/IMessageQueue.spec.ts @@ -19,23 +19,24 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import './mocks'; -import { expect } from 'chai'; -import { EventEmitter as IMQEventEmitter } from '../src'; -import { EventEmitter as NodeEventEmitter } from 'events'; +import '../mocks'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter as IMQEventEmitter } from '../../src'; +import { EventEmitter as NodeEventEmitter } from 'node:events'; -// This test ensures the re-exported EventEmitter from IMessageQueue.ts is exercised -// to cover the function counted by nyc/istanbul for that re-export. describe('IMessageQueue EventEmitter re-export', () => { it('should re-export Node.js EventEmitter and be usable', () => { // Ensure it is the same constructor - expect(IMQEventEmitter).to.equal(NodeEventEmitter); + assert.equal(IMQEventEmitter, NodeEventEmitter); // And it works as expected when instantiated const ee = new IMQEventEmitter(); let called = 0; - ee.on('ping', () => { called++; }); + ee.on('ping', () => { + called++; + }); ee.emit('ping'); - expect(called).to.equal(1); + assert.equal(called, 1); }); }); diff --git a/test/unit/RedisQueue.spec.ts b/test/unit/RedisQueue.spec.ts new file mode 100644 index 0000000..3f4e53e --- /dev/null +++ b/test/unit/RedisQueue.spec.ts @@ -0,0 +1,1953 @@ +/*! + * RedisQueue Unit Tests + * + * Merged suite: core RedisQueue behaviour plus cleanup, cleanup grace period, + * connect() fallbacks, error handling, lifecycle, processCleanup branches, + * processDelayed branches, publish, safe delivery, send and unsubscribe. + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import '../mocks'; +import assert from 'node:assert/strict'; +import { randomUUID as uuid } from 'node:crypto'; +import { describe, it, beforeEach, afterEach, mock, Mock } from 'node:test'; +import Redis from 'ioredis'; +import { RedisQueue, IMQMode } from '../../src'; +import { escapeRegExp, sha1 } from '../../src/helpers'; +import { makeLogger } from '../helpers'; +import { logger, RedisClientMock } from '../mocks'; + +process.setMaxListeners(100); + +const QS = (): any => (RedisClientMock as any).__queues__; +const CL = (): any => (RedisClientMock as any).__clientList; + +describe('RedisQueue', () => { + it('should be a class', () => { + assert.equal(typeof RedisQueue, 'function'); + }); + + it('should implement IMessageQueue interface', () => { + assert.equal(typeof RedisQueue.prototype.start, 'function'); + assert.equal(typeof RedisQueue.prototype.stop, 'function'); + assert.equal(typeof RedisQueue.prototype.send, 'function'); + assert.equal(typeof RedisQueue.prototype.destroy, 'function'); + }); + + describe('constructor()', () => { + it('should not throw', async () => { + const instances: RedisQueue[] = []; + assert.doesNotThrow(() => instances.push(new (RedisQueue)())); + assert.doesNotThrow(() => + instances.push(new RedisQueue('IMQUnitTests')), + ); + assert.doesNotThrow(() => + instances.push(new RedisQueue('IMQUnitTests', {})), + ); + assert.doesNotThrow(() => + instances.push( + new RedisQueue('IMQUnitTests', { useGzip: true }), + ), + ); + + await Promise.all(instances.map(instance => instance.destroy())); + }); + }); + + describe('start()', () => { + it('should throw if no name provided', async () => { + const rq = new (RedisQueue)(); + try { + await rq.start(); + } catch (err) { + assert.ok(err instanceof TypeError); + } + rq.destroy().catch(); + }); + + it('should create reader connection', async () => { + try { + const rq: any = new RedisQueue(uuid(), { logger }); + await rq.start(); + assert.ok(rq.reader instanceof Redis); + await rq.destroy(); + } catch (err) { + console.error(err); + } + }); + + it('should create shared writer connection', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + await rq.start(); + assert.ok(rq.writer instanceof Redis); + await rq.destroy(); + }); + + it('should create single watcher connection', async () => { + const rq1: any = new RedisQueue(uuid(), { logger }); + const rq2: any = new RedisQueue(uuid(), { logger }); + await rq1.start(); + await rq2.start(); + assert.equal(await rq1.watcherCount(), 1); + assert.equal(await rq2.watcherCount(), 1); + await rq1.destroy(); + await rq2.destroy(); + }); + + it('should restart stopped queue', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + await rq.start(); + await rq.stop(); + await rq.start(); + assert.ok(rq.reader instanceof Redis); + await rq.destroy(); + }); + + it('should not fail on double start', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + let passed = true; + try { + await rq.start(); + await rq.start(); + } catch { + passed = false; + } + assert.equal(passed, true); + await rq.destroy(); + }); + }); + + describe('stop()', () => { + it('should stop reading messages from queue', async () => { + const name = uuid(); + const rq: any = new RedisQueue(name, { logger }); + await rq.start(); + assert.ok(rq.reader instanceof Redis); + await rq.stop(); + assert.ok(!rq.reader); + await rq.destroy(); + }); + }); + + describe('send()', () => { + it('should send given message to a given queue', (t, done) => { + const message: any = { hello: 'world' }; + const rqFrom = new RedisQueue('IMQUnitTestsFrom', { logger }); + const rqTo = new RedisQueue('IMQUnitTestsTo', { logger }); + + rqTo.on('message', (msg, id, from) => { + assert.deepEqual(msg, message); + assert.notEqual(id, undefined); + assert.equal(from, 'IMQUnitTestsFrom'); + rqFrom.destroy().catch(); + rqTo.destroy().catch(); + done(); + }); + + rqFrom.start().then(() => { + rqTo.start().then(() => { + rqFrom.send('IMQUnitTestsTo', message).catch(); + }); + }); + }); + + it('should guaranty message delivery if safeDelivery is on', (t, done) => { + // it is hard to emulate mq crash at a certain time of + // its runtime execution, so we simply assume delivery works itself + // for the moment. dumb test but better than nothing :( + const message: any = { hello: 'safe delivery' }; + const rq = new RedisQueue('IMQSafe', { + logger, + safeDelivery: true, + }); + + rq.on('message', msg => { + assert.deepEqual(msg, message); + rq.destroy().catch(); + done(); + }); + + rq.start().then(async () => rq.send('IMQSafe', message)); + }); + + it('should deliver message with the given delay', (t, done) => { + const message: any = { hello: 'world' }; + const delay: number = 1000; + const rqFrom = new RedisQueue('IMQUnitTestsFromD', { logger }); + const rqTo = new RedisQueue('IMQUnitTestsToD', { logger }); + + let start: number; + + rqTo.on('message', (msg, id, from) => { + assert.ok(Date.now() - start >= delay); + assert.deepEqual(msg, message); + assert.notEqual(id, undefined); + assert.equal(from, 'IMQUnitTestsFromD'); + rqFrom.destroy().catch(); + rqTo.destroy().catch(); + done(); + }); + + rqFrom.start().then(() => { + rqTo.start().then(() => { + start = Date.now(); + rqFrom.send('IMQUnitTestsToD', message, delay).catch(); + }); + }); + }); + + it('should emit an error and keep reading on invalid message', (t, done) => { + const message: any = { hello: 'safe delivery' }; + const rq: any = new RedisQueue('IMQSafeErr', { + logger, + safeDelivery: true, + safeDeliveryTtl: 500, + }); + let sawError = false; + + rq.on('error', () => { + sawError = true; + }); + rq.on('message', (msg: any) => { + assert.deepEqual(msg, message); + assert.ok( + sawError, + 'error must be emitted for the invalid message', + ); + rq.destroy().catch(() => undefined); + done(); + }); + + rq.start().then(async () => { + // inject an invalid payload directly into the queue list; + // the read loop must emit an error for it and survive to + // deliver the next (valid) message + (Redis as any).__queues__['imq:IMQSafeErr'] = ['%invalid%']; + await rq.send('IMQSafeErr', message); + }); + }); + }); + + describe('destroy()', () => { + let rq: any; + + beforeEach(async () => { + rq = new RedisQueue(uuid(), { logger }); + await rq.start(); + }); + + it('should destroy all connections', async () => { + await rq.destroy(); + assert.ok(!rq.watcher); + assert.ok(!rq.reader); + assert.ok(!rq.writer); + }); + + it('should remove all event listeners', async () => { + await rq.destroy(); + assert.equal(rq.listenerCount(), 0); + }); + }); + + describe('clear()', () => { + it('should clean-up queue data in redis', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + await rq.start(); + rq.clear(); + assert.ok(!(await rq.writer.exists(rq.key))); + assert.ok(!(await rq.writer.exists(`${rq.key}:delayed`))); + rq.destroy().catch(); + }); + }); + + describe('processCleanup()', () => { + it('should perform cleanup when cleanup option is enabled', async () => { + const rq: any = new RedisQueue(uuid(), { + logger, + cleanup: true, + cleanupFilter: 'test*', + }); + await rq.start(); + + // Call processCleanup directly + const result = await rq.processCleanup(); + assert.equal(result, rq); + + await rq.destroy(); + }); + + it('should return early when cleanup option is disabled', async () => { + const rq: any = new RedisQueue(uuid(), { + logger, + cleanup: false, + }); + await rq.start(); + + const result = await rq.processCleanup(); + assert.equal(result, undefined); + + await rq.destroy(); + }); + }); + + describe('lock/unlock methods', () => { + it('should handle lock/unlock when writer is null', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + // Don't start, so writer will be null + + const lockResult = await rq.lock(); + assert.equal(lockResult, false); + + const unlockResult = await rq.unlock(); + assert.equal(unlockResult, false); + + const isLockedResult = await rq.isLocked(); + assert.equal(isLockedResult, false); + + await rq.destroy(); + }); + + it('should handle lock/unlock operations', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + await rq.start(); + + // Test locking + const lockResult = await rq.lock(); + assert.equal(typeof lockResult, 'boolean'); + + // Test checking if locked + const isLockedResult = await rq.isLocked(); + assert.equal(typeof isLockedResult, 'boolean'); + + // Test unlocking + const unlockResult = await rq.unlock(); + assert.equal(typeof unlockResult, 'boolean'); + + await rq.destroy(); + }); + }); + + describe('utility methods', () => { + it('should test isPublisher and isWorker methods', async () => { + const publisherQueue = new RedisQueue( + uuid(), + { logger }, + IMQMode.PUBLISHER, + ); + const workerQueue = new RedisQueue( + uuid(), + { logger }, + IMQMode.WORKER, + ); + + assert.equal(publisherQueue.isPublisher(), true); + assert.equal(publisherQueue.isWorker(), false); + + assert.equal(workerQueue.isPublisher(), false); + assert.equal(workerQueue.isWorker(), true); + + await workerQueue.destroy(); + await publisherQueue.destroy(); + }); + + it('should test key and lockKey methods', async () => { + const name = uuid(); + const rq: any = new RedisQueue(name, { logger }); + + assert.equal(typeof rq.key, 'string'); + assert.ok(String(rq.key).includes(name)); + + assert.equal(typeof rq.lockKey, 'string'); + assert.ok(String(rq.lockKey).includes('watch:lock')); + + await rq.destroy(); + }); + }); +}); + +describe('RedisQueue.processCleanup catch path', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('should log a warning when processCleanup throws', async () => { + const logger = makeLogger(); + const warnSpy: Mock = mock.method(logger, 'warn'); + const rq: any = new RedisQueue('CleanupCatch', { + logger, + cleanup: true, + }); + + await rq.start(); + // Stub writer.client to throw to hit the catch branch + mock.method(rq.writer, 'client', () => { + throw new Error('LIST failed'); + }); + + await rq.processCleanup(); + + assert.ok(warnSpy.mock.callCount() > 0); + + await rq.destroy(); + }); +}); + +describe('RedisQueue.processCleanup() grace period', () => { + afterEach(() => mock.restoreAll()); + + it('gives vanished clients one sweep of grace before deleting keys', async t => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'CleanGrace', + { logger, cleanup: true, cleanupFilter: '*' }, + IMQMode.PUBLISHER, + ); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + + const client = 'imq:Gone:writer:pid:1:host:h'; + QS()['imq:Gone'] = ['pending']; + CL()[client] = true; + mock.method(rq.writer, 'scan', async () => ['0', ['imq:Gone']]); + + await rq.processCleanup(); // sweep 1: client connected → protected + assert.ok(QS()['imq:Gone'], 'connected client keys are protected'); + + delete CL()[client]; + + await rq.processCleanup(); // sweep 2: just vanished → grace period + assert.ok( + QS()['imq:Gone'], + 'recently-vanished client keys must get one sweep of grace', + ); + + await rq.processCleanup(); // sweep 3: still gone → delete + assert.equal(QS()['imq:Gone'], undefined); + }); +}); + +describe('RedisQueue.connect() option fallbacks', () => { + it('should use fallback values when falsy options are provided', async () => { + const logger = makeLogger(); + // Intentionally provide falsy values to trigger `||` fallbacks in connect() + const rq: any = new RedisQueue( + 'ConnFallbacks', + { + logger, + port: 0 as unknown as number, // falsy to trigger 6379 fallback + host: '' as unknown as string, // falsy to trigger 'localhost' fallback + prefix: '' as unknown as string, // falsy to trigger '' fallback in connectionName + cleanup: false, + }, + IMQMode.BOTH, + ); + + await rq.start(); + + // Basic sanity: writer/reader/watcher are created + assert.equal(Boolean(rq.writer), true); + assert.equal(Boolean(rq.reader), true); + assert.equal(Boolean(rq.watcher), true); + + await rq.destroy(); + }); +}); + +describe('RedisQueue error handling', () => { + afterEach(() => mock.restoreAll()); + + it('emitError does not throw when no error listener is attached', () => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'NoListener', + { logger }, + IMQMode.PUBLISHER, + ); + + assert.doesNotThrow(() => + rq.emitError('OnTest', 'test failure', new Error('x')), + ); + }); + + it('computes lua script checksums at construction time', () => { + const logger = makeLogger(); + const rq: any = new RedisQueue('Sha', { logger }, IMQMode.PUBLISHER); + + assert.equal( + rq.scripts.moveDelayed.checksum, + sha1(rq.scripts.moveDelayed.code), + ); + }); + + it('processDelayed falls back to EVAL when script is not cached', async t => { + const logger = makeLogger(); + const rq: any = new RedisQueue('EvalFb', { logger }, IMQMode.PUBLISHER); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + rq.on('error', () => undefined); + + mock.method(rq.writer, 'evalsha', () => { + throw new Error('NOSCRIPT No matching script.'); + }); + + const evalCalls: any[] = []; + rq.writer.eval = (...args: any[]) => { + evalCalls.push(args); + return Promise.resolve(0); + }; + + await rq.processDelayed(rq.key); + + assert.equal(evalCalls.length, 1, 'EVAL fallback must be used'); + }); + + it('escapes regex metacharacters (escapeRegExp)', () => { + assert.equal(escapeRegExp('my.app*x?'), 'my\\.app\\*x\\?'); + assert.equal(escapeRegExp('plain'), 'plain'); + }); +}); + +describe('RedisQueue lifecycle', () => { + afterEach(() => mock.restoreAll()); + + it('does not register signal handlers when handleSignals is false', async t => { + const logger = makeLogger(); + const before = process.listenerCount('SIGTERM'); + const rq: any = new RedisQueue( + 'NoSig', + { logger, handleSignals: false }, + IMQMode.PUBLISHER, + ); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + + assert.equal(process.listenerCount('SIGTERM'), before); + }); + + it('registers at most one process-level handler for many queues', async t => { + const logger = makeLogger(); + const before = process.listenerCount('SIGTERM'); + const a: any = new RedisQueue('Sig1', { logger }, IMQMode.PUBLISHER); + const b: any = new RedisQueue('Sig2', { logger }, IMQMode.PUBLISHER); + await a.start(); + t.after(() => a.destroy().catch(() => undefined)); + await b.start(); + t.after(() => b.destroy().catch(() => undefined)); + + assert.ok(process.listenerCount('SIGTERM') - before <= 1); + }); + + it('destroy() should NOT clear queue data by default', async t => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'KeepData', + { logger }, + IMQMode.PUBLISHER, + ); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + QS()['imq:KeepData'] = ['pending-message']; + + await rq.destroy(); + + assert.deepEqual( + QS()['imq:KeepData'], + ['pending-message'], + 'destroying a handle must not wipe shared queue data', + ); + delete QS()['imq:KeepData']; + }); + + it('destroy(true) should clear queue data', async t => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'WipeData', + { logger }, + IMQMode.PUBLISHER, + ); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + QS()['imq:WipeData'] = ['pending-message']; + + await rq.destroy(true); + + assert.equal(QS()['imq:WipeData'], undefined); + }); + + it('re-elects a watcher via watcherCheckDelay after owner crash', async t => { + const logger = makeLogger(); + // take control of the shared watcher world + (RedisClientMock as any).__clientList = {}; + (RedisClientMock as any).__keys = {}; + + const rq: any = new RedisQueue('ReElect', { + logger, + watcherCheckDelay: 40, + }); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + + assert.ok(rq.watchOwner, 'first instance should own the watcher'); + + // simulate an owner crash observed from outside: watcher connection + // gone, stale lock left behind + rq.destroyChannel('watcher', rq); + delete (RedisQueue as any).watchers[rq.redisKey]; + rq.watchOwner = false; + + await new Promise(resolve => setTimeout(resolve, 250)); + + assert.ok( + rq.watchOwner, + 'watcher must be re-elected by the periodic check', + ); + }); + + it('re-subscribes and re-attaches handler after subscription reconnect', async t => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'SubRestore', + { logger }, + IMQMode.PUBLISHER, + ); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + + const received: any[] = []; + await rq.subscribe('SubRestore', (data: any) => received.push(data)); + + const oldChan = rq.subscription; + + // simulate what scheduleReconnect does: replace the client + rq.destroyChannel('subscription', rq); + rq.subscription = undefined; + await rq.connect('subscription', rq.options); + + assert.notEqual(rq.subscription, oldChan); + + rq.subscription?.emit( + 'message', + 'imq:SubRestore', + JSON.stringify({ ok: 1 }), + ); + + assert.deepEqual( + received, + [{ ok: 1 }], + 'handler must survive a subscription reconnect', + ); + }); + + it('unsubscribe() survives a rejecting quit()', async t => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'QuitRej', + { logger }, + IMQMode.PUBLISHER, + ); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + await rq.subscribe('QuitRej', () => undefined); + + rq.subscription.quit = () => Promise.reject(new Error('boom')); + + await assert.doesNotReject(rq.unsubscribe()); + }); +}); + +describe('RedisQueue.processCleanup connectedKeys RX/filter combinations', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('should handle RX_CLIENT_TEST true but filter false case (exclude unmatched prefix)', async () => { + const name = `PCleanRX_${uuid()}`; + const rq: any = new RedisQueue(name, { + logger: console, + cleanup: true, + prefix: 'imqA', + cleanupFilter: '*', + }); + + await rq.start(); + + const writer: any = rq.writer; + + // Stub client('LIST') to include a writer channel with a different prefix, + // so RX_CLIENT_TEST.test(name) is true but filter.test(name) is false. + mock.method(writer, 'client', async (cmd: string) => { + if (cmd === 'LIST') { + return [ + 'id=1 name=imqZ:Other:writer:pid:1:host:x', // RX true, filter false + 'id=2 name=imqA:Other:subscription:pid:1:host:x', // RX false, filter true + ].join('\n'); + } + return true as any; + }); + + // Return no keys on SCAN to avoid deletions and just walk the branch + mock.method(writer, 'scan', async () => ['0', []] as any); + + const delSpy: Mock = mock.method(writer, 'del'); + + await rq.processCleanup(); + + assert.equal(delSpy.mock.callCount() > 0, false); + + await rq.destroy(); + }); +}); + +describe('RedisQueue.processCleanup extra branches', () => { + it('should remove scanned keys that do not match any connectedKey (different prefix)', async () => { + const name = uuid(); + const rq: any = new RedisQueue(name, { + logger: console, + cleanup: true, + prefix: 'imqX', + cleanupFilter: '*', + }); + + // start to create reader/writer/watcher with connection names + await rq.start(); + + // Create an orphan worker key with a different prefix so it won't include any connectedKey + const orphanKey = 'imqY:orphan:worker:someuuid:123456'; + (RedisClientMock as any).__queues__[orphanKey] = ['payload']; + + // Sanity: ensure the key is present before cleanup + assert.ok((RedisClientMock as any).__queues__[orphanKey]); + + await rq.processCleanup(); + + // The orphan key should be deleted by cleanup (true branch of keysToRemove filter) + assert.equal((RedisClientMock as any).__queues__[orphanKey], undefined); + + await rq.destroy(); + }); +}); + +describe('RedisQueue.processCleanup multi-scan/no-delete branches', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('should handle multi-page SCAN (cursor != "0" first) and avoid deletion when keys belong to connected clients', async () => { + const name = `PClean_${uuid()}`; + const rq: any = new RedisQueue(name, { + logger: console, + cleanup: true, + prefix: 'imq', + cleanupFilter: '*', + }); + + await rq.start(); + + const writer: any = rq.writer; + + // Stub scan to first return non-zero cursor with undefined keys (to exercise `|| []`), + // then return zero cursor with keys that include connectedKey (so no removal happens). + const scanStub: Mock = mock.method( + writer, + 'scan', + async () => undefined, + ); + scanStub.mock.mockImplementationOnce( + async () => ['1', undefined] as any, + 0, + ); + scanStub.mock.mockImplementationOnce( + async () => ['0', [`imq:${name}:reader:pid:123`]] as any, + 1, + ); + + const delSpy: Mock = mock.method(writer, 'del'); + + await rq.processCleanup(); + + // del should not be called because keysToRemove.length === 0 + assert.equal(delSpy.mock.callCount() > 0, false); + + await rq.destroy(); + }); +}); + +describe('RedisQueue.processCleanup null-match and falsy cleanupFilter', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it("should handle clients.match returning null and cleanupFilter as falsy ('')", async () => { + const name = `PCleanNull_${uuid()}`; + const rq: any = new RedisQueue(name, { + logger: console, + cleanup: true, + prefix: 'imq', + cleanupFilter: '', // falsy to exercise "|| '*'" in both RegExp and SCAN MATCH + }); + + await rq.start(); + + const writer: any = rq.writer; + + // Force clients.match(...) to return null by stubbing client('LIST') to return a string without 'name=' + mock.method(writer, 'client', async (cmd: string) => { + if (cmd === 'LIST') { + return 'id=1 flags=x'; // no 'name=' + } + return true as any; + }); + + // Ensure SCAN returns no keys, to avoid deletions and just cover the branch paths + mock.method(writer, 'scan', async () => ['0', []] as any); + + const delSpy: Mock = mock.method(writer, 'del'); + + await rq.processCleanup(); + + assert.equal(delSpy.mock.callCount() > 0, false); + + await rq.destroy(); + }); +}); + +describe('RedisQueue.processDelayed extra branches', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('should emit error when script execution fails', async t => { + const logger = makeLogger(); + const rq: any = new RedisQueue('ProcessDelayedCatch', { logger }); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + + const emitErrorStub: Mock = mock.method( + (RedisQueue as any).prototype, + 'emitError', + () => undefined, + ); + + // non-NOSCRIPT failures must be reported through emitError + mock.method(rq.writer, 'evalsha', () => { + throw new Error('evalsha failed'); + }); + + await rq['processDelayed'](rq.key); + + assert.ok(emitErrorStub.mock.callCount() > 0); + assert.equal( + emitErrorStub.mock.calls[0].arguments[0], + 'OnProcessDelayed', + ); + }); + + it('should be a silent no-op when writer is not connected', async t => { + const logger = makeLogger(); + const rq: any = new RedisQueue('ProcessDelayedNoWriter', { logger }); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + + const emitErrorStub: Mock = mock.method( + (RedisQueue as any).prototype, + 'emitError', + () => undefined, + ); + + const originalWriter = rq.writer; + rq['writer'] = undefined; + + await rq['processDelayed'](rq.key); + + rq['writer'] = originalWriter; + + assert.equal(emitErrorStub.mock.callCount(), 0); + }); +}); + +describe('RedisQueue.publish()', () => { + it('should throw when writer is not connected', async () => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'PubNoWriter', + { logger }, + IMQMode.PUBLISHER, + ); + + let thrown: any; + try { + await rq.publish({ a: 1 }); + } catch (err) { + thrown = err; + } + + assert.ok(thrown instanceof TypeError); + assert.ok(String(`${thrown}`).includes('Writer is not connected')); + + await rq.destroy().catch(() => undefined); + }); + + it('should publish to default channel when writer is connected', async () => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'PubDefault', + { logger }, + IMQMode.PUBLISHER, + ); + await rq.start(); + + const pubSpy = mock.method((rq as any).writer, 'publish'); + await rq.publish({ hello: 'world' }); + + assert.equal(pubSpy.mock.callCount() > 0, true); + const [channel, msg] = pubSpy.mock.calls[0].arguments; + assert.equal(channel, 'imq:PubDefault'); + assert.doesNotThrow(() => JSON.parse(msg)); + + mock.restoreAll(); + await rq.destroy().catch(() => undefined); + }); + + it('should publish to provided toName channel when given', async () => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'PubOther', + { logger }, + IMQMode.PUBLISHER, + ); + await rq.start(); + + const pubSpy = mock.method((rq as any).writer, 'publish'); + await rq.publish({ t: true }, 'OtherChannel'); + + assert.equal(pubSpy.mock.callCount() > 0, true); + const [channel] = pubSpy.mock.calls[0].arguments; + assert.equal(channel, 'imq:OtherChannel'); + + mock.restoreAll(); + await rq.destroy().catch(() => undefined); + }); +}); + +describe('RedisQueue safe delivery lease handling', () => { + afterEach(() => mock.restoreAll()); + + it('processKeys: requeues messages whose lease deadline expired', async t => { + const logger = makeLogger(); + const rq: any = new RedisQueue('LeaseExp', { + logger, + safeDelivery: true, + }); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + + const expired = `imq:LeaseExp:worker:abc:${Date.now() - 60000}`; + QS()[expired] = ['MSG']; + + await rq.processKeys([expired], Date.now()); + + assert.deepEqual( + QS()['imq:LeaseExp'] || [], + ['MSG'], + 'expired lease must be moved back to the main queue', + ); + }); + + it('processKeys: leaves in-flight messages with a fresh lease alone', async t => { + const logger = makeLogger(); + const rq: any = new RedisQueue('LeaseFresh', { + logger, + safeDelivery: true, + }); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + + const fresh = `imq:LeaseFresh:worker:abc:${Date.now() + 60000}`; + QS()[fresh] = ['MSG']; + + await rq.processKeys([fresh], Date.now()); + + assert.deepEqual( + QS()[fresh], + ['MSG'], + 'fresh lease must not be stolen from a live worker', + ); + assert.equal((QS()['imq:LeaseFresh'] || []).length, 0); + }); + + // regression guard for the bounded-timeout read loop: a message arriving + // long after the reader started must still be delivered + it('delivers safe-mode messages that arrive after pop timeouts', (t, done) => { + const logger = makeLogger(); + const message: any = { late: true }; + const rq: any = new RedisQueue('LateSafe', { + logger, + safeDelivery: true, + safeDeliveryTtl: 200, + }); + + rq.on('message', (msg: any) => { + assert.deepEqual(msg, message); + rq.destroy().catch(() => undefined); + done(); + }); + + rq.start().then(() => { + setTimeout(() => { + rq.send('LateSafe', message).catch(() => undefined); + }, 400); + }); + }); +}); + +describe('RedisQueue.send() extra branches', () => { + it('should throw when writer is still uninitialized after start()', async () => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'SendNoWriter', + { logger }, + IMQMode.PUBLISHER, + ); + // Force start to be a no-op so writer remains undefined + mock.method(rq, 'start', async () => rq); + + let thrown: any; + try { + await rq.send('AnyQueue', { test: true }); + } catch (err) { + thrown = err; + } + + assert.ok(thrown instanceof TypeError); + assert.ok(String(`${thrown}`).includes('unable to initialize queue')); + + mock.restoreAll(); + await rq.destroy().catch(() => undefined); + }); +}); + +describe('RedisQueue.send() worker-only mode', () => { + it('should throw when called in WORKER only mode', async () => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'WorkerOnly', + { logger }, + IMQMode.WORKER, + ); + + let thrown: any; + try { + await rq.send('AnyQueue', { test: true }); + } catch (err) { + thrown = err; + } + + assert.ok(thrown instanceof TypeError); + assert.ok(String(`${thrown}`).includes('WORKER only mode')); + + await rq.destroy().catch(() => undefined); + }); +}); + +describe('RedisQueue.unsubscribe()', () => { + it('should cleanup subscription channel when present', async () => { + const logger = makeLogger(); + const rq: any = new RedisQueue('SubUnsub', { logger }); + await rq.start(); + + const handler = mock.fn(); + await rq.subscribe('SubUnsub', handler); + + assert.ok(rq.subscription); + assert.equal(rq.subscriptionName, 'SubUnsub'); + + const unsubSpy = mock.method(rq.subscription, 'unsubscribe'); + const ralSpy = mock.method(rq.subscription, 'removeAllListeners'); + const disconnectSpy = mock.method(rq.subscription, 'disconnect'); + const quitSpy = mock.method(rq.subscription, 'quit'); + + await rq.unsubscribe(); + + assert.equal(unsubSpy.mock.callCount(), 1); + assert.equal(ralSpy.mock.callCount(), 1); + assert.equal(disconnectSpy.mock.callCount(), 1); + assert.equal(quitSpy.mock.callCount(), 1); + assert.equal(rq.subscription, undefined); + assert.equal(rq.subscriptionName, undefined); + + mock.restoreAll(); + + await rq.destroy().catch(() => undefined); + }); +}); + +describe('RedisQueue.queueLength()', () => { + it('should return 0 when the writer is not connected', async () => { + const rq: any = new RedisQueue('QLenNoWriter', { logger }); + + assert.equal(await rq.queueLength(), 0); + + await rq.destroy().catch(() => undefined); + }); + + it('should return the number of pending messages in the queue', async () => { + const rq: any = new RedisQueue('QLenCount', { logger }); + await rq.start(); + + assert.equal(await rq.queueLength(), 0); + + await rq.send('QLenCount', { n: 1 }); + await rq.send('QLenCount', { n: 2 }); + + assert.equal(await rq.queueLength(), 2); + + await rq.destroy(true).catch(() => undefined); + }); +}); + +describe('RedisQueue.available getter', () => { + it('should be available before a writer connection exists', () => { + const rq: any = new RedisQueue('AvailNoWriter', { logger }); + + assert.equal(rq.available, true); + + rq.destroy().catch(() => undefined); + }); + + it('should reflect the writer connection status once started', async () => { + const rq: any = new RedisQueue('AvailStarted', { logger }); + await rq.start(); + + assert.equal(rq.available, true); + + rq.writer.status = 'reconnecting'; + assert.equal(rq.available, false); + + rq.writer.status = 'ready'; + assert.equal(rq.available, true); + + await rq.destroy().catch(() => undefined); + }); +}); + +describe('RedisQueue.onWatchMessage()', () => { + it('should process delayed messages for ttl expiry keys', async () => { + const rq: any = new RedisQueue('WatchTtl', { logger }); + await rq.start(); + + const processDelayed = mock.method(rq, 'processDelayed'); + + // keyspace event: :::ttl + await rq.onWatchMessage('__keyspace__', `imq:WatchTtl:someid:ttl`); + + assert.equal(processDelayed.mock.callCount(), 1); + assert.equal(processDelayed.mock.calls[0].arguments[0], 'imq:WatchTtl'); + + mock.restoreAll(); + await rq.destroy().catch(() => undefined); + }); + + it('should ignore keyspace events that are not ttl expiries', async () => { + const rq: any = new RedisQueue('WatchNonTtl', { logger }); + await rq.start(); + + const processDelayed = mock.method(rq, 'processDelayed'); + + await rq.onWatchMessage('__keyspace__', `imq:WatchNonTtl:someid:set`); + + assert.equal(processDelayed.mock.callCount(), 0); + + mock.restoreAll(); + await rq.destroy().catch(() => undefined); + }); + + it('should emit an error when delayed processing throws', async () => { + const rq: any = new RedisQueue('WatchErr', { logger }); + await rq.start(); + + mock.method(rq, 'processDelayed', () => { + throw new Error('boom'); + }); + const emitError = mock.method(rq, 'emitError'); + + await rq.onWatchMessage('__keyspace__', `imq:WatchErr:someid:ttl`); + + assert.equal(emitError.mock.callCount(), 1); + assert.equal(emitError.mock.calls[0].arguments[0], 'OnWatch'); + + mock.restoreAll(); + await rq.destroy().catch(() => undefined); + }); +}); + +describe('RedisQueue reconnection', () => { + it('scheduleReconnect() marks the channel reconnecting and backs off', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + + const rq: any = new RedisQueue('ReconnSchedule', { logger }); + + rq.scheduleReconnect('reader'); + + assert.equal(rq.reconnecting.reader, true); + assert.equal(rq.reconnectAttempts.reader, 1); + + // a second schedule while already reconnecting is a no-op + rq.scheduleReconnect('reader'); + assert.equal(rq.reconnectAttempts.reader, 1); + + t.mock.timers.reset(); + await rq.destroy().catch(() => undefined); + }); + + it('scheduleReconnect() does nothing once destroyed', async () => { + const rq: any = new RedisQueue('ReconnDestroyed', { logger }); + await rq.destroy().catch(() => undefined); + + rq.scheduleReconnect('writer'); + + assert.equal(rq.reconnecting.writer, undefined); + }); + + it('reconnectNow() re-establishes a channel and resets its counters', async () => { + const rq: any = new RedisQueue('ReconnNow', { logger }); + await rq.start(); + + rq.reconnectAttempts.reader = 3; + rq.reconnecting.reader = true; + + await rq.reconnectNow('reader'); + + assert.equal(rq.reconnectAttempts.reader, 0); + assert.equal(rq.reconnecting.reader, false); + assert.ok(rq.reader instanceof Redis); + + await rq.destroy().catch(() => undefined); + }); + + it('reconnectNow() bails out without reconnecting when destroyed', async () => { + const rq: any = new RedisQueue('ReconnNowDestroyed', { logger }); + await rq.destroy().catch(() => undefined); + + rq.reconnecting.writer = true; + await rq.reconnectNow('writer'); + + assert.equal(rq.reconnecting.writer, false); + }); + + it('reconnectNow() reconnects the writer channel', async () => { + const rq: any = new RedisQueue('ReconnWriter', { logger }); + await rq.start(); + + await rq.reconnectNow('writer'); + + assert.ok(rq.writer instanceof Redis); + + await rq.destroy().catch(() => undefined); + }); + + it('reconnectNow() reconnects the watcher channel', async () => { + const rq: any = new RedisQueue('ReconnWatcher', { logger }); + await rq.start(); + + await rq.reconnectNow('watcher'); + + assert.ok(rq.watcher instanceof Redis); + + await rq.destroy().catch(() => undefined); + }); + + it('reconnectNow() reconnects the subscription channel', async () => { + const rq: any = new RedisQueue('ReconnSub', { logger }); + await rq.start(); + await rq.subscribe('ReconnSub', mock.fn()); + + await rq.reconnectNow('subscription'); + + assert.ok(rq.subscription instanceof Redis); + + mock.restoreAll(); + await rq.destroy().catch(() => undefined); + }); +}); + +describe('RedisQueue.subscribe() validation', () => { + it('rejects when no channel name is provided', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await assert.rejects( + rq.subscribe('', () => undefined), + TypeError, + ); + + await rq.destroy().catch(() => undefined); + }); + + it('rejects when subscribing to a different channel', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.subscribe('ChanA', () => undefined); + + await assert.rejects( + rq.subscribe('ChanB', () => undefined), + /Invalid channel name/, + ); + + await rq.destroy().catch(() => undefined); + }); +}); + +describe('RedisQueue verbose logging & write errors', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('logs through verbose() when the verbose option is enabled', async () => { + const info: Mock = mock.method(logger, 'info'); + const rq: any = new RedisQueue(uuid(), { logger, verbose: true }); + + rq.verbose('hello world'); + + assert.ok(info.mock.callCount() > 0); + + await rq.destroy().catch(() => undefined); + }); + + it('reports LPUSH write errors to the error handler', async () => { + const rq: any = new RedisQueue(uuid(), { logger, verbose: true }); + + await rq.start(); + mock.method(rq.writer, 'lpush', (_k: any, _v: any, cb: any) => { + cb(new Error('lpush failed')); + + return 0; + }); + + const errors: Error[] = []; + + await rq.send('WriteErrTarget', { a: 1 }, undefined, (err: Error) => + errors.push(err), + ); + + assert.equal(errors.length, 1); + assert.match(errors[0].message, /lpush failed/); + + await rq.destroy(true).catch(() => undefined); + }); + + it('reports ZADD write errors for delayed sends', async () => { + const rq: any = new RedisQueue(uuid(), { logger, verbose: true }); + + await rq.start(); + mock.method(rq.writer, 'zadd', (...args: any[]) => { + args[args.length - 1](new Error('zadd failed')); + + return false; + }); + + const errors: Error[] = []; + + await rq.send('DelayErrTarget', { a: 1 }, 1000, (err: Error) => + errors.push(err), + ); + + assert.equal(errors.length, 1); + assert.match(errors[0].message, /zadd failed/); + + await rq.destroy(true).catch(() => undefined); + }); +}); + +describe('RedisQueue connection error & clear failures', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('logs and schedules reconnect on a connection error event', async () => { + const rq: any = new RedisQueue(uuid(), { logger, verbose: true }); + + await rq.start(); + + const schedule: Mock = mock.method( + rq, + 'scheduleReconnect', + () => undefined, + ); + const err: any = new Error('connection refused'); + err.code = 'ECONNREFUSED'; + + rq.writer.emit('error', err); + + assert.ok(schedule.mock.callCount() > 0); + + await rq.destroy().catch(() => undefined); + }); + + it('logs when clearing expired keys fails', async () => { + const errorSpy: Mock = mock.method(logger, 'error'); + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + mock.method(rq.writer, 'del', async () => { + throw new Error('del failed'); + }); + + await rq.clear(); + + assert.ok(errorSpy.mock.callCount() > 0); + + await rq.destroy(true).catch(() => undefined); + }); +}); + +describe('RedisQueue read loops & connection handlers', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('read() logs when the reader is not initialized', async () => { + const errorSpy: Mock = mock.method(logger, 'error'); + const rq: any = new RedisQueue(uuid(), { logger }); + + assert.equal(rq.read(), rq); + assert.ok(errorSpy.mock.callCount() > 0); + + await rq.destroy().catch(() => undefined); + }); + + it('readUnsafe() breaks quietly on a closed connection', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + mock.method(rq.reader, 'brpop', async () => { + throw new Error('Connection is closed'); + }); + + await assert.doesNotReject(rq.readUnsafe()); + + await rq.destroy().catch(() => undefined); + }); + + it('readUnsafe() emits an error on an unexpected reader failure', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + mock.method(rq.reader, 'brpop', async () => { + throw new Error('unexpected boom'); + }); + + const errors: Error[] = []; + rq.on('error', (err: Error) => errors.push(err)); + + await rq.readUnsafe(); + + assert.ok(errors.length > 0); + + await rq.destroy().catch(() => undefined); + }); + + it('readSafe() breaks when the reader connection ends', async () => { + const rq: any = new RedisQueue(uuid(), { + logger, + safeDelivery: true, + }); + + await rq.start(); + mock.method(rq.reader, 'blmove', async () => { + throw new Error('ended'); + }); + + await assert.doesNotReject(rq.readSafe()); + + await rq.destroy(true).catch(() => undefined); + }); + + it('readSafe() survives a message processing failure', async () => { + const rq: any = new RedisQueue(uuid(), { + logger, + safeDelivery: true, + }); + + await rq.start(); + + let n = 0; + mock.method(rq.reader, 'blmove', async () => { + if (n++ === 0) { + return 'a-message'; + } + + rq.destroyed = true; + + return null; + }); + mock.method(rq, 'process', () => { + throw new Error('process failed'); + }); + + const errors: Error[] = []; + rq.on('error', (err: Error) => errors.push(err)); + + await rq.readSafe(); + + assert.ok(errors.length > 0); + + rq.destroyed = false; + await rq.destroy(true).catch(() => undefined); + }); + + it('onCloseHandler() marks uninitialized and schedules reconnect', async () => { + const warnSpy: Mock = mock.method(logger, 'warn'); + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + + const schedule: Mock = mock.method( + rq, + 'scheduleReconnect', + () => undefined, + ); + + rq.onCloseHandler('reader')(); + + assert.equal(rq.initialized, false); + assert.ok(warnSpy.mock.callCount() > 0); + assert.ok(schedule.mock.callCount() > 0); + + await rq.destroy().catch(() => undefined); + }); + + it('process() ignores messages for a different queue', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + assert.equal(rq.process(['some:other:key', 'data']), rq); + + await rq.destroy().catch(() => undefined); + }); + + it('destroyChannel() is a no-op when the channel has no connection', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + assert.doesNotThrow(() => rq.destroyChannel('subscription')); + + await rq.destroy().catch(() => undefined); + }); + + it('reports SET write errors for delayed sends', async () => { + const rq: any = new RedisQueue(uuid(), { logger, verbose: true }); + + await rq.start(); + mock.method(rq.writer, 'zadd', (...args: any[]) => { + args[args.length - 1](null); + + return true; + }); + mock.method(rq.writer, 'set', (...args: any[]) => { + args[args.length - 1](new Error('set failed')); + + return { catch: () => undefined }; + }); + + const errors: Error[] = []; + + await rq.send('SetErrTarget', { a: 1 }, 1000, (err: Error) => + errors.push(err), + ); + + assert.ok(errors.some(err => /set failed/.test(err.message))); + + await rq.destroy(true).catch(() => undefined); + }); +}); + +describe('RedisQueue watcher & connect edge paths', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('connect() returns the existing connection for a channel', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + + const existing = await rq.connect('reader', rq.options); + + assert.equal(existing, rq.reader); + + await rq.destroy().catch(() => undefined); + }); + + it('processWatch() emits an error and clears the interval on scan failure', async () => { + const rq: any = new RedisQueue(uuid(), { + logger, + safeDelivery: true, + }); + + await rq.start(); + mock.method(rq.writer, 'scan', async () => { + throw new Error('scan failed'); + }); + + const errors: Error[] = []; + rq.on('error', (err: Error) => errors.push(err)); + + await rq.processWatch(); + + assert.ok(errors.length > 0); + + await rq.destroy(true).catch(() => undefined); + }); + + it('initWatcher() logs and rethrows when initialization fails', async () => { + const errorSpy: Mock = mock.method(logger, 'error'); + const rq: any = new RedisQueue(uuid(), { logger }); + + mock.method(rq, 'watcherCount', async () => { + throw new Error('watcher count failed'); + }); + + await assert.rejects(rq.initWatcher(), /watcher count failed/); + assert.ok(errorSpy.mock.callCount() > 0); + + await rq.destroy().catch(() => undefined); + }); +}); + +describe('RedisQueue signal, reconnect & watch internals', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('noRetryStrategy disables ioredis retries', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + + assert.equal(rq.reader.options.retryStrategy(), null); + + await rq.destroy().catch(() => undefined); + }); + + it('freeAndExit() releases watcher locks and exits', async () => { + const exit: Mock = mock.method( + process, + 'exit', + (() => undefined) as any, + ); + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + rq.watchOwner = true; + mock.method(rq, 'unlock', async () => { + throw new Error('unlock fail'); + }); + + await (RedisQueue as any).freeAndExit(); + + assert.ok(exit.mock.callCount() > 0); + + await rq.destroy().catch(() => undefined); + }); + + it('bindSignals() wires a shutdown handler that frees and exits', async () => { + mock.method(process, 'exit', (() => undefined) as any); + + const prev = (RedisQueue as any).signalsBound; + (RedisQueue as any).signalsBound = false; + + const before = process.listeners('SIGTERM').slice(); + (RedisQueue as any).bindSignals(); + const added = process + .listeners('SIGTERM') + .filter(l => !before.includes(l)); + + assert.ok(added.length > 0); + + await (added[0] as any)(); + await new Promise(resolve => setImmediate(resolve)); + + for (const sig of ['SIGTERM', 'SIGINT', 'SIGABRT'] as const) { + for (const l of added) { + process.removeListener(sig, l as any); + } + } + + (RedisQueue as any).signalsBound = prev; + }); + + it('runWatcherCheck() returns early when a check is in flight', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + rq.watcherCheckBusy = true; + + await assert.doesNotReject(rq.runWatcherCheck()); + + rq.watcherCheckBusy = false; + await rq.destroy().catch(() => undefined); + }); + + it('runWatcherCheck() contains errors without throwing', async () => { + const rq: any = new RedisQueue(uuid(), { logger, verbose: true }); + + await rq.start(); + rq.watcherCheckBusy = false; + mock.method(rq, 'watcherCount', async () => { + throw new Error('watcher count fail'); + }); + + await assert.doesNotReject(rq.runWatcherCheck()); + + await rq.destroy().catch(() => undefined); + }); + + it('reports LPUSH errors surfaced via a rejected promise', async () => { + const rq: any = new RedisQueue(uuid(), { logger, verbose: true }); + + await rq.start(); + mock.method(rq.writer, 'lpush', () => ({ + catch: (cb: any) => cb(new Error('lpush promise fail')), + })); + + const errors: Error[] = []; + + await rq.send('LpushProm', { a: 1 }, undefined, (err: Error) => + errors.push(err), + ); + + assert.ok(errors.some(err => /lpush promise fail/.test(err.message))); + + await rq.destroy(true).catch(() => undefined); + }); + + it('destroyChannel() logs when quit throws synchronously', async () => { + const rq: any = new RedisQueue(uuid(), { logger, verbose: true }); + + await rq.start(); + mock.method(rq.reader, 'quit', () => { + throw new Error('quit fail'); + }); + + assert.doesNotThrow(() => rq.destroyChannel('reader')); + + // quit was stubbed to throw, so disconnect() never ran to clear the + // mock reader's poll timer — restore and disconnect for real to avoid + // leaking the recurring brpop timer + mock.restoreAll(); + rq.reader?.disconnect(); + + await rq.destroy().catch(() => undefined); + }); + + it('destroyChannel() logs when the forced disconnect throws', async () => { + const rq: any = new RedisQueue(uuid(), { logger, verbose: true }); + + await rq.start(); + mock.method(rq.reader, 'quit', async () => { + throw new Error('quit rejected'); + }); + mock.method(rq.reader, 'disconnect', () => { + throw new Error('disconnect fail'); + }); + + rq.destroyChannel('reader'); + await new Promise(resolve => setImmediate(resolve)); + + // the stubbed disconnect threw before clearing the mock reader's poll + // timer — restore and disconnect for real to release it + mock.restoreAll(); + rq.reader?.disconnect(); + + await rq.destroy().catch(() => undefined); + }); + + it('scheduleReconnect() clears an existing reconnect timer', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + rq.reconnectTimers.reader = setTimeout(() => undefined, 60000); + + rq.scheduleReconnect('reader'); + + assert.ok(rq.reconnectTimers.reader); + clearTimeout(rq.reconnectTimers.reader); + rq.reconnectTimers.reader = undefined; + + await rq.destroy().catch(() => undefined); + }); + + it('reconnectNow() clears the pending timer on success', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + rq.reconnectTimers.reader = setTimeout(() => undefined, 60000); + + await rq.reconnectNow('reader'); + + assert.equal(rq.reconnectTimers.reader, undefined); + + await rq.destroy().catch(() => undefined); + }); + + it('reconnectNow() reschedules on failure', async () => { + const rq: any = new RedisQueue(uuid(), { logger, verbose: true }); + + await rq.start(); + mock.method(rq, 'connect', async () => { + throw new Error('connect fail'); + }); + const schedule: Mock = mock.method( + rq, + 'scheduleReconnect', + () => undefined, + ); + + await rq.reconnectNow('reader'); + + assert.ok(schedule.mock.callCount() > 0); + + await rq.destroy().catch(() => undefined); + }); + + it('onErrorHandler() returns early once destroyed', async () => { + const rq: any = new RedisQueue(uuid(), { logger, verbose: true }); + + await rq.start(); + rq.destroyed = true; + + assert.doesNotThrow(() => + rq.onErrorHandler('reader')(new Error('ignored')), + ); + + rq.destroyed = false; + await rq.destroy().catch(() => undefined); + }); + + it('watcherCount() returns 0 when CLIENT LIST is empty', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + mock.method(rq.writer, 'client', async () => null); + + assert.equal(await rq.watcherCount(), 0); + + await rq.destroy().catch(() => undefined); + }); + + it('watch() returns early without a writer/watcher', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + assert.equal(rq.watch(), rq); + + await rq.destroy().catch(() => undefined); + }); + + it('watch() emits a config error when SET fails', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + rq.writer = { + config: () => { + throw new Error('config fail'); + }, + }; + rq.watcher = { + __ready__: false, + on: () => undefined, + psubscribe: () => ({ catch: () => undefined }), + }; + + const errors: Error[] = []; + rq.on('error', (err: Error) => errors.push(err)); + + rq.watch(); + + assert.ok(errors.length > 0); + + rq.cleanSafeCheckInterval(); + rq.writer = undefined; + rq.watcher = undefined; + await rq.destroy().catch(() => undefined); + }); + + it('runSafeCheck() cleans the interval when the writer is gone', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await assert.doesNotReject(rq.runSafeCheck()); + + await rq.destroy().catch(() => undefined); + }); + + it('ownWatch() emits an error when script loading fails', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + mock.method(rq, 'lock', async () => true); + mock.method(rq, 'connect', async () => rq.writer); + mock.method(rq, 'watch', () => rq); + mock.method(rq.writer, 'script', async () => { + throw new Error('script fail'); + }); + + const errors: Error[] = []; + rq.on('error', (err: Error) => errors.push(err)); + + await rq.ownWatch(); + + assert.ok(errors.length > 0); + + await rq.destroy().catch(() => undefined); + }); +}); + +describe('RedisQueue remaining guards', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('processKeys() returns early for an empty key list', async () => { + const rq: any = new RedisQueue(uuid(), { logger }); + + await assert.doesNotReject(rq.processKeys([], Date.now())); + + await rq.destroy().catch(() => undefined); + }); + + it('freeAndExit() force-exits when unlocking exceeds the timeout', async () => { + const exit: Mock = mock.method( + process, + 'exit', + (() => undefined) as any, + ); + const rq: any = new RedisQueue(uuid(), { logger }); + + await rq.start(); + rq.watchOwner = true; + // an unlock that never settles forces the shutdown fallback timer + mock.method(rq, 'unlock', () => new Promise(() => undefined)); + + void (RedisQueue as any).freeAndExit(); + await new Promise(resolve => setTimeout(resolve, 1200)); + + assert.ok(exit.mock.callCount() > 0); + + mock.restoreAll(); + rq.watchOwner = false; + await rq.destroy().catch(() => undefined); + }); +}); diff --git a/test/unit/UDPClusterManager.spec.ts b/test/unit/UDPClusterManager.spec.ts new file mode 100644 index 0000000..19c9461 --- /dev/null +++ b/test/unit/UDPClusterManager.spec.ts @@ -0,0 +1,523 @@ +/*! + * RedisQueue Unit Tests + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + * + * UDPClusterManager unit tests (merged: main suite, missing branches coverage + * and destroyWorker() behavior). + */ +import '../mocks'; +import { describe, it, afterEach, mock, Mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { UDPClusterManager } from '../../src'; + +const testMessageUp = { + name: 'IMQUnitTest', + id: '1234567890', + type: 'up', + address: '127.0.0.1:6379', + timeout: 50, +}; + +const testMessageDown = { + name: 'IMQUnitTest', + id: '1234567890', + type: 'down', + address: '127.0.0.1:6379', + timeout: 50, +}; + +const getSocket = (classObject: any) => { + return classObject.worker; +}; + +const emitMessage = ( + instanceClass: any, + type: 'cluster:add' | 'cluster:remove', +) => { + getSocket(instanceClass).emit('message', { + type, + server: type === 'cluster:add' ? testMessageUp : testMessageDown, + }); +}; + +describe('UDPBroadcastClusterManager', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('should be a class', () => { + assert.equal(typeof UDPClusterManager, 'function'); + }); + + it('should call add on cluster', async () => { + const cluster: any = { + add: () => {}, + remove: () => {}, + find: () => {}, + }; + const manager: any = new UDPClusterManager(); + + const add = mock.method(cluster, 'add'); + + manager.init(cluster); + + emitMessage(manager, 'cluster:add'); + assert.equal(add.mock.callCount() > 0, true); + await manager.destroy(); + }); + + it('should not call add on cluster if server exists', async () => { + const cluster: any = { + add: () => {}, + remove: () => {}, + find: () => { + return {}; + }, + }; + const manager: any = new UDPClusterManager(); + + const add = mock.method(cluster, 'add'); + + manager.init(cluster); + + emitMessage(manager, 'cluster:add'); + assert.equal(add.mock.callCount() > 0, false); + await manager.destroy(); + }); + + it('should call remove on cluster', async () => { + const cluster: any = { + add: () => {}, + remove: () => {}, + find: () => { + return {}; + }, + }; + const manager: any = new UDPClusterManager(); + + const remove = mock.method(cluster, 'remove'); + + manager.init(cluster); + + emitMessage(manager, 'cluster:remove'); + assert.equal(remove.mock.callCount() > 0, true); + await manager.destroy(); + }); + + it('should handle server timeout and removal', (t, done) => { + let addedServer: any = null; + const cluster: any = { + add: () => {}, + remove: async (server: any) => { + assert.equal(server, addedServer); + await manager.destroy(); + }, + find: (message: any) => { + if (!addedServer) { + addedServer = { + id: message.id, + timer: null, + timestamp: Date.now(), + timeout: 50, // Short timeout for test + }; + return addedServer; + } + return addedServer; + }, + }; + const manager: any = new UDPClusterManager(); + + manager.init(cluster); + + // Send up message to add server with short timeout + emitMessage(manager, 'cluster:add'); + + // Wait for timeout to trigger removal + setTimeout(async () => { + await manager.destroy(); + done(); + }, 1000); + }); + + it('should handle timeout when server no longer exists', async () => { + let serverAdded = false; + const cluster: any = { + add: () => {}, + remove: () => {}, + find: (message: any) => { + if (!serverAdded) { + serverAdded = true; + return { + id: message.id, + timer: null, + timestamp: Date.now(), + timeout: 50, + }; + } + // Return null to simulate server no longer existing + return null; + }, + }; + const manager: any = new UDPClusterManager(); + + manager.init(cluster); + + // This should trigger the timeout handler that returns early (line 307) + emitMessage(manager, 'cluster:add'); + await manager.destroy(); + }); + + describe('destroy()', () => { + it('should be idempotent', async () => { + const manager: any = new UDPClusterManager(); + + await manager.destroy(); + // a repeated destroy must not throw or corrupt refcounts + await manager.destroy(); + + assert.equal( + (UDPClusterManager as any).workerRefs[manager.workerKey], + undefined, + ); + }); + }); +}); + +describe('UDPClusterManager - shared worker fan-out', () => { + afterEach(() => { + mock.restoreAll(); + }); + + it('should deliver worker messages to every manager sharing a worker', async () => { + const clusterOne: any = { + add: mock.fn(), + remove: () => {}, + find: () => undefined, + }; + const clusterTwo: any = { + add: mock.fn(), + remove: () => {}, + find: () => undefined, + }; + const managerOne: any = new UDPClusterManager(); + const managerTwo: any = new UDPClusterManager(); + + // same options must resolve to one shared worker + assert.equal(managerOne.worker, managerTwo.worker); + + managerOne.init(clusterOne); + managerTwo.init(clusterTwo); + + emitMessage(managerOne, 'cluster:add'); + + assert.equal(clusterOne.add.mock.callCount(), 1); + assert.equal(clusterTwo.add.mock.callCount(), 1); + + await managerOne.destroy(); + await managerTwo.destroy(); + }); + + it('should stop delivering messages to a destroyed manager', async () => { + const clusterOne: any = { + add: mock.fn(), + remove: () => {}, + find: () => undefined, + }; + const clusterTwo: any = { + add: mock.fn(), + remove: () => {}, + find: () => undefined, + }; + const managerOne: any = new UDPClusterManager(); + const managerTwo: any = new UDPClusterManager(); + + managerOne.init(clusterOne); + managerTwo.init(clusterTwo); + + await managerOne.destroy(); + + emitMessage(managerTwo, 'cluster:add'); + + assert.equal(clusterOne.add.mock.callCount(), 0); + assert.equal(clusterTwo.add.mock.callCount(), 1); + + await managerTwo.destroy(); + }); + + it('should use separate workers for differing worker options', async () => { + const managerOne: any = new UDPClusterManager(); + const managerTwo: any = new UDPClusterManager({ + aliveTimeoutCorrection: 1234, + }); + + assert.notEqual(managerOne.workerKey, managerTwo.workerKey); + assert.notEqual(managerOne.worker, managerTwo.worker); + + await managerOne.destroy(); + await managerTwo.destroy(); + }); + + it('should log worker "error" events via logger.error', async () => { + const error = mock.fn(); + const manager: any = new UDPClusterManager({ + logger: { log: () => {}, info: () => {}, warn: () => {}, error }, + }); + + manager.worker.emit('error', new Error('worker blew up')); + + assert.equal(error.mock.callCount(), 1); + assert.match( + String(error.mock.calls[0].arguments[1]), + /worker blew up/, + ); + + await manager.destroy(); + }); + + it('should log worker socket errors instead of crashing', async () => { + const warn = mock.fn(); + const manager: any = new UDPClusterManager({ + logger: { log: () => {}, info: () => {}, warn, error: () => {} }, + }); + + manager.worker.emit('message', { + type: 'error', + error: 'bind failed', + }); + + assert.equal(warn.mock.callCount(), 1); + assert.match(String(warn.mock.calls[0].arguments[0]), /bind failed/); + + await manager.destroy(); + }); + + it('should not warn about unexpected exit on graceful destroy', async () => { + const warn = mock.fn(); + const manager: any = new UDPClusterManager({ + logger: { log: () => {}, info: () => {}, warn, error: () => {} }, + }); + const worker = manager.worker; + const exited = new Promise(resolve => + worker.on('exit', () => resolve()), + ); + + await manager.destroy(); + await exited; + // let the supervision 'exit' handler run + await new Promise(resolve => setImmediate(resolve)); + + const warnedUnexpected = warn.mock.calls.some((call: any) => + String(call.arguments[0]).includes('unexpectedly'), + ); + + assert.equal(warnedUnexpected, false); + }); + + it('should not bind process signal handlers when handleSignals is false', async () => { + const previouslyBound = (UDPClusterManager as any).signalsBound; + + (UDPClusterManager as any).signalsBound = false; + + const before = process.listenerCount('SIGABRT'); + const manager: any = new UDPClusterManager({ handleSignals: false }); + + assert.equal(process.listenerCount('SIGABRT'), before); + assert.equal((UDPClusterManager as any).signalsBound, false); + + await manager.destroy(); + (UDPClusterManager as any).signalsBound = previouslyBound; + }); +}); + +describe('UDPClusterManager.destroyWorker()', () => { + it('should resolve when worker is undefined (no-op)', async () => { + const destroy = (UDPClusterManager as any).destroyWorker as Function; + await destroy('0.0.0.0:63000', undefined); + }); + + it('should terminate worker and remove it from the workers map', async () => { + const destroy = (UDPClusterManager as any).destroyWorker as Function; + const workers = (UDPClusterManager as any).workers as Record< + string, + any + >; + const key = '1.2.3.4:65000'; + + let terminated = false; + const fakeWorker: any = { + postMessage: () => {}, + on: (event: string, cb: Function) => { + if (event === 'message') { + // unrelated broadcast noise must not consume the wait + setImmediate(() => cb({ type: 'cluster:add' })); + setImmediate(() => cb({ type: 'stopped' })); + } + }, + off: () => {}, + terminate: () => { + terminated = true; + }, + }; + + workers[key] = fakeWorker; + await destroy(key, fakeWorker); + + assert.equal(terminated, true); + assert.equal(workers[key], undefined); + }); +}); + +describe('UDPClusterManager lifecycle internals', () => { + const noopCluster = (): any => ({ + add: () => undefined, + remove: () => undefined, + find: () => undefined, + }); + + afterEach(() => { + mock.restoreAll(); + (UDPClusterManager as any).shuttingDown = false; + }); + + it('ignores worker messages that are not cluster events', async () => { + const manager: any = new UDPClusterManager(); + + manager.init(noopCluster()); + assert.doesNotThrow(() => + manager.worker.emit('message', { type: 'system:info' }), + ); + + await manager.destroy(); + }); + + it('free() flags shutdown and stops every worker', async () => { + // stub destroyWorker so the real 5s stop-acknowledgement wait (and any + // real worker teardown) is skipped — free()'s own logic is what we test + const destroySpy: Mock = mock.method( + UDPClusterManager as any, + 'destroyWorker', + async () => undefined, + ); + (UDPClusterManager as any).workers['fake:worker'] = { fake: true }; + + await (UDPClusterManager as any).free(); + + assert.equal((UDPClusterManager as any).shuttingDown, true); + assert.ok(destroySpy.mock.callCount() > 0); + + delete (UDPClusterManager as any).workers['fake:worker']; + }); + + it('freeAndRaise() frees workers then re-raises the signal', async () => { + const kill: Mock = mock.method(process, 'kill', () => true); + mock.method( + UDPClusterManager as any, + 'destroyWorker', + async () => undefined, + ); + + await (UDPClusterManager as any).freeAndRaise('SIGTERM'); + + assert.ok(kill.mock.callCount() > 0); + }); + + it('binds a signal handler that frees and re-raises', async () => { + mock.method(process, 'kill', () => true); + mock.method( + UDPClusterManager as any, + 'destroyWorker', + async () => undefined, + ); + + const prev = (UDPClusterManager as any).signalsBound; + (UDPClusterManager as any).signalsBound = false; + + const before = process.listeners('SIGTERM').slice(); + (UDPClusterManager as any).bindSignals(); + const added = process + .listeners('SIGTERM') + .filter(l => !before.includes(l)); + + assert.ok(added.length > 0); + + await (added[0] as any)('SIGTERM'); + + for (const sig of ['SIGTERM', 'SIGINT', 'SIGABRT'] as const) { + for (const l of added) { + process.removeListener(sig, l as any); + } + } + + (UDPClusterManager as any).signalsBound = prev; + }); + + it('warns and schedules a respawn on unexpected worker exit', async () => { + const warn: Mock = mock.fn(); + const manager: any = new UDPClusterManager({ + logger: { log: () => {}, info: () => {}, warn, error: () => {} }, + }); + + manager.init(noopCluster()); + const worker = manager.worker; + + worker.emit('exit', 1); + + const warned = warn.mock.calls.some((call: any) => + String(call.arguments[0]).includes('unexpectedly'), + ); + assert.equal(warned, true); + + await worker.terminate?.(); + await manager.destroy().catch(() => undefined); + }); + + it('respawn() replaces the worker after the delay', async () => { + const manager: any = new UDPClusterManager(); + + manager.init(noopCluster()); + const key = manager.workerKey; + const original = manager.worker; + + delete (UDPClusterManager as any).workers[key]; + (UDPClusterManager as any).respawn(key); + + await new Promise(resolve => setTimeout(resolve, 1200)); + + assert.ok(manager.worker); + + await original.terminate?.(); + await manager.destroy().catch(() => undefined); + }); +}); + +describe('UDPClusterManager.respawn() guard', () => { + afterEach(() => { + mock.restoreAll(); + (UDPClusterManager as any).shuttingDown = false; + }); + + it('is a no-op without registered instances', () => { + assert.doesNotThrow(() => + (UDPClusterManager as any).respawn('missing-worker-key'), + ); + }); +}); diff --git a/test/unit/UDPWorker.spec.ts b/test/unit/UDPWorker.spec.ts new file mode 100644 index 0000000..4fc952f --- /dev/null +++ b/test/unit/UDPWorker.spec.ts @@ -0,0 +1,257 @@ +/*! + * UDPWorker Unit Tests + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import '../mocks'; +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { UDPWorker } from '../../src/UDPWorker'; + +const OPTIONS: any = { + port: 63999, + address: '255.255.255.255', + limitedAddress: '255.255.255.255', + aliveTimeoutCorrection: 5000, + useAliveCheck: false, +}; + +const makePort = (): any => { + const port: any = new EventEmitter(); + + port.postMessage = mock.fn(); + + return port; +}; + +const makeWorker = (options: any = OPTIONS): { worker: any; port: any } => { + const port = makePort(); + const worker: any = new UDPWorker(options, port); + + return { worker, port }; +}; + +const datagram = (...fields: Array): Buffer => + Buffer.from(fields.join('\t')); + +const postedMessages = (port: any): any[] => + port.postMessage.mock.calls.map((call: any) => call.arguments[0]); + +describe('UDPWorker', () => { + it('should post cluster:add for a valid up datagram', () => { + const { worker, port } = makeWorker(); + + worker.socket.emit( + 'message', + datagram('IMQ', 'id-1', 'UP', '127.0.0.1:6379', '30'), + ); + + assert.deepEqual(postedMessages(port), [ + { + type: 'cluster:add', + server: { + id: 'id-1', + name: 'IMQ', + type: 'up', + host: '127.0.0.1', + port: 6379, + timeout: 30000, + }, + }, + ]); + }); + + it('should post cluster:remove for a down datagram', () => { + const { worker, port } = makeWorker(); + + worker.socket.emit( + 'message', + datagram('IMQ', 'id-1', 'down', '127.0.0.1:6379', '30'), + ); + + const [message] = postedMessages(port); + + assert.equal(message.type, 'cluster:remove'); + assert.equal(message.server.id, 'id-1'); + }); + + it('should drop malformed datagrams without crashing', () => { + const { worker, port } = makeWorker(); + + worker.socket.emit('message', Buffer.from('complete garbage')); + worker.socket.emit('message', Buffer.from('')); + worker.socket.emit('message', datagram('IMQ', 'id-1')); + + assert.equal(port.postMessage.mock.callCount(), 0); + }); + + it('should drop datagrams with a non-numeric or invalid port', () => { + const { worker, port } = makeWorker(); + + worker.socket.emit( + 'message', + datagram('IMQ', 'id-1', 'up', '127.0.0.1:oops', '30'), + ); + worker.socket.emit( + 'message', + datagram('IMQ', 'id-2', 'up', '127.0.0.1:0', '30'), + ); + worker.socket.emit( + 'message', + datagram('IMQ', 'id-3', 'up', '127.0.0.1:70000', '30'), + ); + + assert.equal(port.postMessage.mock.callCount(), 0); + }); + + it('should drop datagrams with a non-numeric or negative timeout', () => { + const { worker, port } = makeWorker(); + + worker.socket.emit( + 'message', + datagram('IMQ', 'id-1', 'up', '127.0.0.1:6379', 'NaN'), + ); + worker.socket.emit( + 'message', + datagram('IMQ', 'id-2', 'up', '127.0.0.1:6379', '-5'), + ); + + assert.equal(port.postMessage.mock.callCount(), 0); + }); + + it('should forward socket errors to the main thread', () => { + const { worker, port } = makeWorker(); + + worker.socket.emit('error', new Error('EADDRINUSE')); + + assert.deepEqual(postedMessages(port), [ + { type: 'error', error: 'EADDRINUSE' }, + ]); + }); + + it('should reply with stopped on a stop message', () => { + const { port } = makeWorker(); + + port.emit('message', { type: 'stop' }); + + assert.deepEqual(postedMessages(port), [{ type: 'stopped' }]); + }); + + it('should reply with stopped even without a socket', () => { + const { worker, port } = makeWorker(); + + worker.socket = undefined; + port.emit('message', { type: 'stop' }); + + assert.deepEqual(postedMessages(port), [{ type: 'stopped' }]); + }); + + it('should swallow errors thrown while handling a datagram', () => { + const { worker, port } = makeWorker(); + + // a null payload makes parseMessage throw inside the handler; the + // worker must not crash and must not post anything + assert.doesNotThrow(() => worker.socket.emit('message', null)); + assert.equal(port.postMessage.mock.callCount(), 0); + }); + + it('selects a matching IPv4 interface for the broadcast address', () => { + const { worker } = makeWorker({ + ...OPTIONS, + address: '127.0.0.255', + limitedAddress: '255.255.255.255', + }); + + assert.equal(worker.selectNetworkInterface(), '127.0.0.1'); + }); + + it('skips interface entries with no addresses', () => { + const os = require('node:os'); + const original = os.networkInterfaces; + + os.networkInterfaces = () => ({ + empty: undefined, + lo: [ + { + address: '127.0.0.1', + family: 'IPv4', + internal: true, + }, + ], + }); + + try { + const { worker } = makeWorker({ + ...OPTIONS, + address: '127.0.0.255', + limitedAddress: '255.255.255.255', + }); + + assert.equal(worker.selectNetworkInterface(), '127.0.0.1'); + } finally { + os.networkInterfaces = original; + } + }); + + it('should remove a server after its alive timeout expires', async () => { + const { worker, port } = makeWorker({ + ...OPTIONS, + useAliveCheck: true, + aliveTimeoutCorrection: 0, + }); + + worker.socket.emit( + 'message', + datagram('IMQ', 'id-1', 'up', '127.0.0.1:6379', '0.001'), + ); + + await new Promise(resolve => setTimeout(resolve, 50)); + + const types = postedMessages(port).map(message => message.type); + + assert.deepEqual(types, ['cluster:add', 'cluster:remove']); + }); + + it('should keep a server alive while heartbeats keep arriving', async () => { + const { worker, port } = makeWorker({ + ...OPTIONS, + useAliveCheck: true, + aliveTimeoutCorrection: 0, + }); + const heartbeat = (): void => + worker.socket.emit( + 'message', + datagram('IMQ', 'id-1', 'up', '127.0.0.1:6379', '0.03'), + ); + + heartbeat(); + await new Promise(resolve => setTimeout(resolve, 15)); + // the fresh heartbeat re-stamps the server, invalidating the + // previous liveness timer + heartbeat(); + await new Promise(resolve => setTimeout(resolve, 15)); + + const types = postedMessages(port).map(message => message.type); + + assert.deepEqual(types, ['cluster:add', 'cluster:add']); + }); +}); diff --git a/test/unit/helpers/buildOptions.spec.ts b/test/unit/helpers/buildOptions.spec.ts new file mode 100644 index 0000000..c097631 --- /dev/null +++ b/test/unit/helpers/buildOptions.spec.ts @@ -0,0 +1,65 @@ +/*! + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildOptions } from '../../../src/helpers'; + +interface Options { + host: string; + port: number; + verbose?: boolean; +} + +describe('buildOptions()', () => { + const defaults: Options = { host: 'localhost', port: 6379 }; + + it('should return defaults when no options are given', () => { + assert.deepEqual(buildOptions(defaults), defaults); + }); + + it('should override defaults with given values', () => { + const result = buildOptions(defaults, { port: 7000, verbose: true }); + + assert.deepEqual(result, { + host: 'localhost', + port: 7000, + verbose: true, + }); + }); + + it('should not mutate the defaults object', () => { + buildOptions(defaults, { port: 9999 }); + + assert.equal(defaults.port, 6379); + }); + + it('should return a new object instance', () => { + assert.notEqual(buildOptions(defaults), defaults); + }); + + it('should apply a partial override without dropping other defaults', () => { + const result = buildOptions(defaults, { host: 'redis.local' }); + + assert.equal(result.host, 'redis.local'); + assert.equal(result.port, 6379); + }); +}); diff --git a/test/copyEventEmitter.ts b/test/unit/helpers/copyEventEmitter.spec.ts similarity index 73% rename from test/copyEventEmitter.ts rename to test/unit/helpers/copyEventEmitter.spec.ts index 99a50de..b43b62b 100644 --- a/test/copyEventEmitter.ts +++ b/test/unit/helpers/copyEventEmitter.spec.ts @@ -19,12 +19,13 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import './mocks'; -import { EventEmitter } from 'events'; -import { expect } from 'chai'; -import { copyEventEmitter } from '../src'; +import '../../mocks'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { copyEventEmitter } from '../../../src/helpers'; -describe('copyEventEmitter()', function() { +describe('copyEventEmitter()', () => { const eventName = 'test'; it('should have the same number of listeners', () => { @@ -34,7 +35,7 @@ describe('copyEventEmitter()', function() { source.on('test', () => {}); copyEventEmitter(source, target); - expect(target.listenerCount('test')).to.be.equal(1); + assert.equal(target.listenerCount('test'), 1); }); it('should copy the same listener on', () => { @@ -48,7 +49,7 @@ describe('copyEventEmitter()', function() { const targetListener = target.listeners(eventName)[0]; const sourceListener = source.listeners(eventName)[0]; - expect(targetListener).to.be.equal(sourceListener); + assert.equal(targetListener, sourceListener); }); it('should copy the same listener once', () => { @@ -61,7 +62,7 @@ describe('copyEventEmitter()', function() { const targetListener = target.listeners(eventName)[0]; const sourceListener = source.listeners(eventName)[0]; - expect(targetListener).to.be.equal(sourceListener); + assert.equal(targetListener, sourceListener); }); it('should set same max listeners count', () => { @@ -74,7 +75,7 @@ describe('copyEventEmitter()', function() { const targetListenersCount = target.getMaxListeners(); const sourceListenersCount = source.getMaxListeners(); - expect(targetListenersCount).to.be.equal(sourceListenersCount); + assert.equal(targetListenersCount, sourceListenersCount); }); it('should handle listeners without listener property', () => { @@ -82,17 +83,17 @@ describe('copyEventEmitter()', function() { const target = new EventEmitter(); // Create a mock listener that looks like onceWrapper but has no listener property - const mockListener = function() {}; + const mockListener = function () {}; Object.defineProperty(mockListener, 'toString', { - value: () => 'function onceWrapper() { ... }' + value: () => 'function onceWrapper() { ... }', }); // Manually add the listener to simulate the edge case source.on(eventName, mockListener); // Mock util.inspect to return onceWrapper for this listener - const originalInspect = require('util').inspect; - require('util').inspect = (obj: any) => { + const originalInspect = require('node:util').inspect; + require('node:util').inspect = (obj: any) => { if (obj === mockListener) { return 'function onceWrapper() { ... }'; } @@ -102,9 +103,9 @@ describe('copyEventEmitter()', function() { copyEventEmitter(source, target); // Restore original inspect - require('util').inspect = originalInspect; + require('node:util').inspect = originalInspect; - expect(target.listenerCount(eventName)).to.be.equal(1); + assert.equal(target.listenerCount(eventName), 1); }); it('should handle source without _maxListeners property', () => { @@ -117,7 +118,7 @@ describe('copyEventEmitter()', function() { source.on(eventName, () => {}); copyEventEmitter(source, target); - expect(target.listenerCount(eventName)).to.be.equal(1); + assert.equal(target.listenerCount(eventName), 1); }); it('should handle once listeners with listener property', () => { @@ -129,17 +130,17 @@ describe('copyEventEmitter()', function() { copyEventEmitter(source, target); - expect(target.listenerCount(eventName)).to.be.equal(1); + assert.equal(target.listenerCount(eventName), 1); }); it('should handle onceWrapper-like listener with falsy listener property', () => { const source = new EventEmitter(); const target = new EventEmitter(); // Create a mock listener that looks like onceWrapper and has a falsy listener property - const mockListener: any = function() {}; + const mockListener: any = function () {}; mockListener.listener = 0; // falsy value present - const originalInspect = require('util').inspect; - require('util').inspect = (obj: any) => { + const originalInspect = require('node:util').inspect; + require('node:util').inspect = (obj: any) => { if (obj === mockListener) { return 'function onceWrapper() { ... }'; } @@ -150,19 +151,19 @@ describe('copyEventEmitter()', function() { copyEventEmitter(source, target); // Restore original inspect - require('util').inspect = originalInspect; + require('node:util').inspect = originalInspect; - expect(target.listenerCount(eventName)).to.be.equal(1); + assert.equal(target.listenerCount(eventName), 1); }); it('should handle onceWrapper-like listener with undefined listener property', () => { const source = new EventEmitter(); const target = new EventEmitter(); - const mockListener: any = function() {}; + const mockListener: any = function () {}; mockListener.listener = undefined; // explicitly undefined - const originalInspect = require('util').inspect; - require('util').inspect = (obj: any) => { + const originalInspect = require('node:util').inspect; + require('node:util').inspect = (obj: any) => { if (obj === mockListener) { return 'function onceWrapper() { ... }'; } @@ -173,9 +174,9 @@ describe('copyEventEmitter()', function() { copyEventEmitter(source, target); // Restore original inspect - require('util').inspect = originalInspect; + require('node:util').inspect = originalInspect; - expect(target.listenerCount(eventName)).to.be.equal(1); + assert.equal(target.listenerCount(eventName), 1); }); it('should handle onceWrapper-like listener with truthy listener property', () => { @@ -184,12 +185,14 @@ describe('copyEventEmitter()', function() { // Create a mock listener that looks like onceWrapper and has a truthy listener property let called = 0; - const realListener = () => { called++; }; - const mockListener: any = function() {}; + const realListener = () => { + called++; + }; + const mockListener: any = function () {}; mockListener.listener = realListener; // truthy function - const originalInspect = require('util').inspect; - require('util').inspect = (obj: any) => { + const originalInspect = require('node:util').inspect; + require('node:util').inspect = (obj: any) => { if (obj === mockListener) { return 'function onceWrapper() { ... }'; } @@ -200,13 +203,13 @@ describe('copyEventEmitter()', function() { copyEventEmitter(source, target); // Restore original inspect - require('util').inspect = originalInspect; + require('node:util').inspect = originalInspect; // Ensure the listener was attached via once() and is callable exactly once - expect(target.listenerCount(eventName)).to.be.equal(1); + assert.equal(target.listenerCount(eventName), 1); target.emit(eventName); target.emit(eventName); - expect(called).to.equal(1); + assert.equal(called, 1); }); it('should handle onceWrapper path when originalListener is undefined', () => { @@ -218,11 +221,13 @@ describe('copyEventEmitter()', function() { }; const onceCalls: any[] = []; const target: any = { - once: (ev: any, listener: any) => { onceCalls.push([ev, listener]); }, + once: (ev: any, listener: any) => { + onceCalls.push([ev, listener]); + }, on: () => {}, }; - const originalInspect = require('util').inspect; - require('util').inspect = (obj: any) => { + const originalInspect = require('node:util').inspect; + require('node:util').inspect = (obj: any) => { if (typeof obj === 'undefined') { return 'function onceWrapper() { ... }'; } @@ -232,10 +237,10 @@ describe('copyEventEmitter()', function() { copyEventEmitter(source as any, target as any); // Restore original inspect - require('util').inspect = originalInspect; + require('node:util').inspect = originalInspect; - expect(onceCalls.length).to.equal(1); - expect(onceCalls[0][0]).to.equal(eventName); - expect(onceCalls[0][1]).to.equal(undefined); + assert.equal(onceCalls.length, 1); + assert.equal(onceCalls[0][0], eventName); + assert.equal(onceCalls[0][1], undefined); }); }); diff --git a/test/unit/helpers/envInt.spec.ts b/test/unit/helpers/envInt.spec.ts new file mode 100644 index 0000000..ba3c768 --- /dev/null +++ b/test/unit/helpers/envInt.spec.ts @@ -0,0 +1,60 @@ +/*! + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { envInt } from '../../../src/helpers'; + +const VAR = 'IMQ_ENV_INT_TEST'; + +describe('envInt()', () => { + afterEach(() => { + delete process.env[VAR]; + }); + + it('should read an integer value from the environment', () => { + process.env[VAR] = '4242'; + + assert.equal(envInt(VAR, 1), 4242); + }); + + it('should fall back to the default when the variable is unset', () => { + assert.equal(envInt(VAR, 555), 555); + }); + + it('should fall back to the default for non-numeric values', () => { + process.env[VAR] = 'not-a-number'; + + assert.equal(envInt(VAR, 7), 7); + }); + + it('should read a zero value set explicitly', () => { + process.env[VAR] = '0'; + + assert.equal(envInt(VAR, 99), 0); + }); + + it('should parse negative integers', () => { + process.env[VAR] = '-15'; + + assert.equal(envInt(VAR, 0), -15); + }); +}); diff --git a/test/unit/helpers/escapeRegExp.spec.ts b/test/unit/helpers/escapeRegExp.spec.ts new file mode 100644 index 0000000..bd103bc --- /dev/null +++ b/test/unit/helpers/escapeRegExp.spec.ts @@ -0,0 +1,55 @@ +/*! + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { escapeRegExp } from '../../../src/helpers'; + +describe('escapeRegExp()', () => { + it('should leave plain alphanumeric strings unchanged', () => { + assert.equal(escapeRegExp('imq123'), 'imq123'); + }); + + it('should escape every regexp special character', () => { + assert.equal( + escapeRegExp('.*+?^${}()|[]\\'), + '\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\', + ); + }); + + it('should produce a pattern that matches the literal input', () => { + const literal = 'a.b*c(d)'; + const rx = new RegExp(`^${escapeRegExp(literal)}$`); + + assert.ok(rx.test(literal)); + }); + + it('should prevent metacharacters from matching non-literally', () => { + const rx = new RegExp(`^${escapeRegExp('a.c')}$`); + + assert.ok(rx.test('a.c')); + assert.ok(!rx.test('axc')); + }); + + it('should return an empty string unchanged', () => { + assert.equal(escapeRegExp(''), ''); + }); +}); diff --git a/test/unit/helpers/pack.spec.ts b/test/unit/helpers/pack.spec.ts new file mode 100644 index 0000000..58bea3a --- /dev/null +++ b/test/unit/helpers/pack.spec.ts @@ -0,0 +1,63 @@ +/*! + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { gunzipSync } from 'node:zlib'; +import { pack } from '../../../src/helpers'; + +describe('pack()', () => { + it('should return a string', () => { + assert.equal(typeof pack({ hello: 'world' }), 'string'); + }); + + it('should produce output that gunzips back to the source JSON', () => { + const data = { a: 1, b: [true, null, 'x'], c: { nested: 'value' } }; + const packed = pack(data); + const restored = gunzipSync(Buffer.from(packed, 'binary')).toString(); + + assert.equal(restored, JSON.stringify(data)); + }); + + it('should be deterministic for equal input', () => { + const data = { list: [1, 2, 3], flag: false }; + + assert.equal(pack(data), pack(data)); + }); + + it('should compress large repetitive payloads below raw JSON size', () => { + const data = { items: Array.from({ length: 1000 }, () => 'repeat') }; + const packed = pack(data); + + assert.ok(packed.length < JSON.stringify(data).length); + }); + + it('should handle primitive and null values', () => { + assert.equal( + gunzipSync(Buffer.from(pack(null), 'binary')).toString(), + 'null', + ); + assert.equal( + gunzipSync(Buffer.from(pack(42), 'binary')).toString(), + '42', + ); + }); +}); diff --git a/test/unit/helpers/randomInt.spec.ts b/test/unit/helpers/randomInt.spec.ts new file mode 100644 index 0000000..1b9e703 --- /dev/null +++ b/test/unit/helpers/randomInt.spec.ts @@ -0,0 +1,58 @@ +/*! + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomInt } from '../../../src/helpers'; + +describe('randomInt()', () => { + it('should always return an integer within the inclusive range', () => { + for (let i = 0; i < 10000; i++) { + const value = randomInt(1, 10); + + assert.ok(Number.isInteger(value)); + assert.ok(value >= 1 && value <= 10, `out of range: ${value}`); + } + }); + + it('should return the bound when min equals max', () => { + assert.equal(randomInt(7, 7), 7); + }); + + it('should support negative ranges', () => { + for (let i = 0; i < 1000; i++) { + const value = randomInt(-5, -1); + + assert.ok(value >= -5 && value <= -1, `out of range: ${value}`); + } + }); + + it('should be able to hit both range boundaries', () => { + const seen = new Set(); + + for (let i = 0; i < 5000; i++) { + seen.add(randomInt(0, 1)); + } + + assert.ok(seen.has(0)); + assert.ok(seen.has(1)); + }); +}); diff --git a/test/unit/helpers/sha1.spec.ts b/test/unit/helpers/sha1.spec.ts new file mode 100644 index 0000000..d28e94f --- /dev/null +++ b/test/unit/helpers/sha1.spec.ts @@ -0,0 +1,45 @@ +/*! + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { sha1 } from '../../../src/helpers'; + +describe('sha1()', () => { + it('should return a 40-character lowercase hex string', () => { + const hash = sha1('imqueue'); + + assert.match(hash, /^[0-9a-f]{40}$/); + }); + + it('should match known SHA-1 test vectors', () => { + assert.equal(sha1(''), 'da39a3ee5e6b4b0d3255bfef95601890afd80709'); + assert.equal(sha1('abc'), 'a9993e364706816aba3e25717850c26c9cd0d89d'); + }); + + it('should be deterministic for equal input', () => { + assert.equal(sha1('same'), sha1('same')); + }); + + it('should produce different hashes for different input', () => { + assert.notEqual(sha1('a'), sha1('b')); + }); +}); diff --git a/test/unit/helpers/unpack.spec.ts b/test/unit/helpers/unpack.spec.ts new file mode 100644 index 0000000..f90c240 --- /dev/null +++ b/test/unit/helpers/unpack.spec.ts @@ -0,0 +1,64 @@ +/*! + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { pack, unpack } from '../../../src/helpers'; + +describe('unpack()', () => { + it('should reverse pack() for objects', () => { + const data = { id: '1', message: { hello: 'world' }, from: 'q' }; + + assert.deepEqual(unpack(pack(data)), data); + }); + + it('should round-trip every JSON value type', () => { + const values: unknown[] = [ + null, + true, + false, + 0, + -12.34, + 'string', + [], + [1, 'two', false, null], + { a: { b: { c: [1, 2, 3] } } }, + ]; + + for (const value of values) { + assert.deepEqual(unpack(pack(value)), value); + } + }); + + it('should preserve unicode content', () => { + const data = { text: 'Привіт 🌍 — çà va?' }; + + assert.deepEqual(unpack(pack(data)), data); + }); + + it('should preserve nested arrays and objects deeply', () => { + const data = { + level1: { level2: { level3: [{ x: 1 }, { y: [true, null] }] } }, + }; + + assert.deepEqual(unpack(pack(data)), data); + }); +}); diff --git a/test/IMQ.ts b/test/unit/index.spec.ts similarity index 56% rename from test/IMQ.ts rename to test/unit/index.spec.ts index a26abbe..8123ae2 100644 --- a/test/IMQ.ts +++ b/test/unit/index.spec.ts @@ -21,44 +21,51 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import './mocks'; -import { expect } from 'chai'; -import IMQ, { RedisQueue, ClusteredRedisQueue } from '..'; +import '../mocks'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import IMQ, { RedisQueue, ClusteredRedisQueue } from '../..'; describe('IMQ', () => { - it('should be a class', () => { - expect(typeof IMQ).to.equal('function'); + assert.equal(typeof IMQ, 'function'); }); describe('create()', () => { it('should return proper object', () => { - expect(IMQ.create('IMQUnitTest', { vendor: 'Redis' })) - .instanceof(RedisQueue); + assert.ok( + IMQ.create('IMQUnitTest', { vendor: 'Redis' }) instanceof + RedisQueue, + ); }); it('should throw if unknown vendor specified', () => { - expect(() => IMQ.create('IMQUnitTest', { vendor: 'JudgmentDay' })) - .to.throw(Error); + assert.throws( + () => IMQ.create('IMQUnitTest', { vendor: 'JudgmentDay' }), + Error, + ); }); it('should allow to be called with no options', () => { - expect(() => IMQ.create('IMQUnitTest')) - .not.to.throw(Error); + assert.doesNotThrow(() => IMQ.create('IMQUnitTest')); }); it('should return clustered object if cluster options passed', () => { - expect(IMQ.create('IMQUnitTest', { - vendor: 'Redis', - cluster: [{ - host: 'localhost', - port: 1111 - }, { - host: 'localhost', - port: 2222 - }] - })).instanceof(ClusteredRedisQueue); + assert.ok( + IMQ.create('IMQUnitTest', { + vendor: 'Redis', + cluster: [ + { + host: 'localhost', + port: 1111, + }, + { + host: 'localhost', + port: 2222, + }, + ], + }) instanceof ClusteredRedisQueue, + ); }); }); - }); diff --git a/test/unit/profile.spec.ts b/test/unit/profile.spec.ts new file mode 100644 index 0000000..0e237d5 --- /dev/null +++ b/test/unit/profile.spec.ts @@ -0,0 +1,378 @@ +/*! + * profile() Function & Decorator Unit Tests (merged: profile() behavior, + * logDebugInfo branches, decorator extra branches, and async rejection path) + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import '../mocks'; +import { describe, it, beforeEach, afterEach, mock, Mock } from 'node:test'; +import assert from 'node:assert/strict'; +import mockRequire from 'mock-require'; +import { + profile, + ILogger, + verifyLogLevel, + LogLevel, + DebugInfoOptions, +} from '../..'; +import { logger } from '../mocks'; + +const BIG_INT_SUPPORT = (() => { + try { + return !!BigInt(0); + } catch { + return false; + } +})(); + +function calledWithMatch(spy: Mock, re: RegExp): boolean { + return spy.mock.calls.some((call: any) => + call.arguments.some((arg: any) => re.test(String(arg))), + ); +} + +class ProfiledClass { + private logger: ILogger = logger; + + @profile() + public decoratedMethod(...args: any[]) { + return args; + } + + @profile({ + enableDebugTime: true, + enableDebugArgs: true, + logLevel: LogLevel.LOG, + }) + public async decoratedAsyncMethod() { + return new Promise(resolve => setTimeout(resolve, 10)); + } +} + +class ProfiledClassTimed { + private logger: ILogger = logger; + + @profile({ + enableDebugTime: true, + logLevel: LogLevel.LOG, + }) + public decoratedMethod() {} +} + +class ProfiledClassArgued { + private logger: ILogger = logger; + + @profile({ + enableDebugTime: false, + enableDebugArgs: true, + logLevel: LogLevel.LOG, + }) + public decoratedMethod() {} +} + +class ProfiledClassTimedAndArgued { + private logger: ILogger = logger; + + @profile({ + enableDebugTime: true, + enableDebugArgs: true, + logLevel: LogLevel.LOG, + }) + public decoratedMethod() {} +} + +describe('profile()', () => { + let log: Mock; + let error: Mock; + + beforeEach(() => { + log = mock.method(logger, 'log'); + error = mock.method(logger, 'error'); + // spied to suppress profiler output during tests (not asserted on) + mock.method(logger, 'warn'); + mock.method(logger, 'info'); + }); + + afterEach(() => { + mock.restoreAll(); + mockRequire.stopAll(); + delete process.env.IMQ_LOG_TIME_FORMAT; + }); + + it('should be a function', () => { + assert.equal(typeof profile, 'function'); + }); + + it('should be decorator factory', () => { + assert.equal(typeof profile(), 'function'); + }); + + it('should pass decorated method args with no change', () => { + assert.deepEqual( + new ProfiledClass().decoratedMethod(1, 2, 3), + [1, 2, 3], + ); + }); + + it('should log time if enabled', () => { + new ProfiledClassTimed().decoratedMethod(); + assert.equal(log.mock.callCount(), 1); + }); + + it('should log args if enabled', () => { + new ProfiledClassArgued().decoratedMethod(); + assert.equal(log.mock.callCount(), 1); + }); + + it('should log time and args if both enabled', () => { + new ProfiledClassTimedAndArgued().decoratedMethod(); + assert.equal(log.mock.callCount(), 2); + }); + + it('should handle async methods correctly', async () => { + await new ProfiledClass().decoratedAsyncMethod(); + assert.equal(log.mock.callCount(), 2); + }); + + describe('verifyLogLevel()', () => { + it('should return valid log levels as is', () => { + assert.equal(verifyLogLevel(LogLevel.LOG), LogLevel.LOG); + assert.equal(verifyLogLevel(LogLevel.INFO), LogLevel.INFO); + assert.equal(verifyLogLevel(LogLevel.WARN), LogLevel.WARN); + assert.equal(verifyLogLevel(LogLevel.ERROR), LogLevel.ERROR); + }); + + it('should return default log level on invalid value', () => { + assert.equal(verifyLogLevel('invalid'), LogLevel.INFO); + }); + }); + + describe('logDebugInfo()', () => { + const start = BIG_INT_SUPPORT ? BigInt(1) : 1; + const baseOptions: DebugInfoOptions = { + debugTime: true, + debugArgs: true, + className: 'TestClass', + args: [1, 'a', { b: 2 }], + methodName: 'testMethod', + start, + logger, + logLevel: LogLevel.LOG, + }; + + it('should log time in microseconds by default', () => { + const { logDebugInfo } = mockRequire.reRequire('../../src/profile'); + logDebugInfo(baseOptions); + assert.equal(calledWithMatch(log, /μs/), true); + }); + + it('should log time in milliseconds', () => { + process.env.IMQ_LOG_TIME_FORMAT = 'milliseconds'; + const { logDebugInfo } = mockRequire.reRequire('../../src/profile'); + logDebugInfo(baseOptions); + assert.equal(calledWithMatch(log, /ms/), true); + }); + + it('should log time in seconds', () => { + process.env.IMQ_LOG_TIME_FORMAT = 'seconds'; + const { logDebugInfo } = mockRequire.reRequire('../../src/profile'); + logDebugInfo(baseOptions); + assert.equal(calledWithMatch(log, /sec/), true); + }); + + it('should handle circular references in args', () => { + const { logDebugInfo } = mockRequire.reRequire('../../src/profile'); + const a: any = { b: 1 }; + const b = { a }; + a.b = b; + logDebugInfo({ ...baseOptions, args: [a] }); + assert.equal(error.mock.callCount(), 0); + }); + + it('should not log when logger method is missing', () => { + const { logDebugInfo } = mockRequire.reRequire('../../src/profile'); + const dummyLogger: any = { error: logger.error.bind(logger) }; + logDebugInfo({ + ...baseOptions, + logger: dummyLogger, + logLevel: 'nonexistent' as any, + }); + assert.equal(log.mock.callCount(), 0); + }); + + it('should handle JSON.stringify errors', () => { + const { logDebugInfo } = mockRequire.reRequire('../../src/profile'); + const badJson = { + toJSON: () => { + throw new Error('bad json'); + }, + }; + logDebugInfo({ ...baseOptions, args: [badJson] }); + assert.equal(error.mock.callCount(), 1); + }); + }); +}); + +describe('profile.ts additional branches', () => { + afterEach(() => { + mock.restoreAll(); + mockRequire.stopAll(); + delete (process as any).env.IMQ_LOG_TIME_FORMAT; + }); + + it('logDebugInfo: should not attempt to call missing log method (no-op path)', () => { + const { logDebugInfo, LogLevel } = + mockRequire.reRequire('../../src/profile'); + const fakeLogger: any = { + // intentionally no 'log' or 'info' method for selected level + error: mock.fn(), + }; + const options = { + debugTime: true, + debugArgs: true, + className: 'X', + args: [1, { a: 2 }], + methodName: 'm', + start: (process.hrtime as any).bigint(), + logger: fakeLogger, + logLevel: LogLevel.LOG, + }; + assert.doesNotThrow(() => logDebugInfo(options)); + // ensures error not called due to serialization success + assert.equal(fakeLogger.error.mock.callCount() > 0, false); + }); + + it('logDebugInfo: should call logger.error on JSON.stringify error (BigInt arg)', () => { + const { logDebugInfo, LogLevel } = + mockRequire.reRequire('../../src/profile'); + const fakeLogger: any = { + error: mock.fn(), + }; + const args = [BigInt(1)]; // JSON.stringify throws on BigInt + const options = { + debugTime: false, + debugArgs: true, + className: 'Y', + args, + methodName: 'n', + start: (process.hrtime as any).bigint(), + logger: fakeLogger, + logLevel: LogLevel.INFO, + }; + logDebugInfo(options); + assert.equal(fakeLogger.error.mock.callCount(), 1); + }); +}); + +describe('profile decorator extra branches', () => { + it('should return early via original.apply(target, ...) when both debug flags are false and this is undefined', () => { + class T1 { + @profile({ + enableDebugTime: false, + enableDebugArgs: false, + logLevel: LogLevel.LOG, + }) + public m(...args: any[]) { + return args; + } + } + const o = new T1(); + const fn = Object.getPrototypeOf(o).m as Function; // wrapper + const res = fn.call(undefined, 1, 2, 3); + assert.deepEqual(res, [1, 2, 3]); + }); + + it('should execute debug path with (this || target) picking target and logLevel fallback to IMQ_LOG_LEVEL', () => { + class T2 { + // no logger on prototype; calling with undefined this picks target + @profile({ + enableDebugTime: true, + enableDebugArgs: true, + logLevel: undefined as any, + }) + public m(..._args: any[]) { + /* noop */ + } + } + const o = new T2(); + const fn = Object.getPrototypeOf(o).m as Function; // wrapper + // provide serializable args to avoid logger.error path when logger is undefined + assert.doesNotThrow(() => fn.call(undefined, 1, { a: 2 }, 'x')); + }); +}); + +class RejectingClass { + public logger: any = { info: () => undefined, error: () => undefined }; + + @profile({ enableDebugTime: true, enableDebugArgs: true }) + public async willReject(): Promise { + return Promise.reject(new Error('boom')); + } +} + +describe('profile() async rejection path', () => { + it('should log via logger when async method rejects', async () => { + const logger = { info: mock.fn(), error: () => undefined } as any; + const obj = new RejectingClass(); + obj.logger = logger; + try { + await obj.willReject(); + } catch { + // expected + } + // allow microtask queue + await new Promise(res => setTimeout(res, 0)); + assert.ok(logger.info.mock.callCount() > 0); + }); +}); + +describe('profile() legacy decorator signature', () => { + it('wraps via the legacy (target, key, descriptor) form', () => { + const decorator = profile({ enableDebugTime: true }); + const original = function (): number { + return 42; + }; + const descriptor: any = { value: original }; + + const result = decorator({}, 'legacyMethod', descriptor); + + assert.equal(result, descriptor); + assert.notEqual(descriptor.value, original); + assert.equal(descriptor.value(), 42); + }); + + it('resolves the class name from a function target', () => { + const decorator = profile({ enableDebugTime: true }); + const descriptor: any = { + value: function (): number { + return 7; + }, + }; + + decorator({}, 'staticMethod', descriptor); + + // invoking with `this` bound to a class (a function) exercises the + // function-target branch of class-name resolution + class StaticHolder {} + + assert.equal(descriptor.value.call(StaticHolder), 7); + }); +}); diff --git a/test/uuid.ts b/test/uuid.ts deleted file mode 100644 index 7543f65..0000000 --- a/test/uuid.ts +++ /dev/null @@ -1,73 +0,0 @@ -/*! - * uuid() Function Unit Tests - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ -import './mocks'; -import { expect } from 'chai'; -import { uuid } from '..'; - -describe('uuid()', function() { - this.timeout(10000); // 10 seconds - - const steps = 100000; - - it('should always match RFC4122 spec', () => { - const regex = new RegExp( - '^' + - '[0-9a-f]{8}' + - '-' + - '[0-9a-f]{4}' + - '-' + - '[1-5][0-9a-f]{3}' + - '-' + - '[89ab][0-9a-f]{3}' + - '-' + - '[0-9a-f]{12}' + - '$', - 'i', - ); - - for (let i = 0; i < 1000; i++) { - expect(regex.test(uuid())).to.be.true; - } - }); - - it('should be unique each time generated (100,000 samples)', () => { - const keyStore: { [name: string]: number } = {}; - - for (let i = 0; i < steps; i++) { - keyStore[uuid()] = 1; - } - - expect(Object.keys(keyStore).length).to.be.equal(steps); - }); - - it('should generate 100,000 identifiers in less than a second', () => { - const start = Date.now(); - - for (let i = 0; i < steps; i++) { - uuid(); - } - - expect(Date.now() - start).to.be.below(1000); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 7b13412..741cae9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,24 @@ { "compilerOptions": { - "module": "commonjs", + "target": "es2023", + "lib": ["es2023"], + "moduleDetection": "force", + + "module": "nodenext", + "moduleResolution": "nodenext", + "resolveJsonModule": true, + "esModuleInterop": true, + + "isolatedModules": true, + "declaration": true, - "noImplicitAny": true, - "strictNullChecks": true, - "removeComments": false, - "noUnusedLocals": false, - "noUnusedParameters": false, - "moduleResolution": "node", "sourceMap": true, "inlineSources": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "target": "es2017", - "lib": [ - "dom", - "es2017" - ] + "removeComments": false, + "newLine": "lf", + + "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true } } diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..3e53bb6 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "name": "@imqueue/core", + "entryPoints": ["."], + "entryPointStrategy": "expand", + "exclude": [ + "**/+(test|node_modules|docs|coverage|benchmark|.nyc_output)/**/*", + "**/*.d.ts" + ], + "excludePrivate": true, + "excludeExternals": true, + "hideGenerator": true, + "externalSymbolLinkMappings": { + "@types/node": { + "EventEmitter.defaultMaxListeners": "https://nodejs.org/api/events.html#eventsdefaultmaxlisteners" + } + }, + "out": "docs" +}