From 41156cf2d9dba9fca601edd2e65ce291f8c3c37a Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Mon, 6 Jul 2026 19:30:16 +0200 Subject: [PATCH 01/20] feat: removed not reliable dev deps, reorganized tests & moved to node native test structure --- .github/workflows/build.yml | 20 +- .gitignore | 1 + README.md | 13 +- benchmark/affinity.ts | 2 +- benchmark/index.ts | 338 +- benchmark/redis-test.ts | 207 +- eslint.config.mjs | 98 - index.ts | 6 +- package-lock.json | 6013 +++-------------- package.json | 48 +- src/ClusterManager.ts | 19 +- src/ClusteredRedisQueue.ts | 225 +- src/IMessageQueue.ts | 24 +- src/RedisQueue.ts | 393 +- src/UDPClusterManager.ts | 59 +- src/UDPWorker.ts | 46 +- src/copyEventEmitter.ts | 4 +- src/index.ts | 1 - src/profile.ts | 108 +- src/promisify.ts | 113 - src/uuid.ts | 40 +- ...edRedisQueue.addServer.defaultInit.spec.ts | 34 - ...usteredRedisQueue.addServer.noInit.spec.ts | 32 - test/ClusteredRedisQueue.extra.spec.ts | 48 - test/ClusteredRedisQueue.initialize.spec.ts | 37 - test/ClusteredRedisQueue.matchServers.spec.ts | 23 - test/ClusteredRedisQueue.ts | 344 - test/RedisQueue.cleanup.catch.spec.ts | 41 - test/RedisQueue.connect.fallbacks.spec.ts | 40 - ...Queue.processCleanup.clientsFilter.spec.ts | 53 - test/RedisQueue.processCleanup.extra.spec.ts | 38 - ...edisQueue.processCleanup.multiscan.spec.ts | 42 - ...edisQueue.processCleanup.nullmatch.spec.ts | 49 - test/RedisQueue.publish.spec.ts | 70 - test/RedisQueue.send.extra.branches.spec.ts | 41 - test/RedisQueue.send.worker.mode.spec.ts | 36 - test/RedisQueue.unsubscribe.spec.ts | 53 - ...UDPClusterManager.missing.branches.spec.ts | 39 - test/helpers/index.ts | 38 + test/mocks/dgram.ts | 2 +- test/mocks/logger.ts | 2 +- test/mocks/os.ts | 2 +- test/mocks/redis.ts | 58 +- test/profile.decorator.branches.spec.ts | 34 - test/profile.more.branches.spec.ts | 57 - test/profile.rejection.spec.ts | 33 - test/profile.ts | 219 - test/promisify.ts | 94 - test/{ => unit}/ClusterManager.spec.ts | 36 +- .../ClusteredRedisQueue/AddServer.spec.ts | 71 + .../ClusteredRedisQueue.spec.ts | 398 ++ .../ClusteredRedisQueue/Initialize.spec.ts | 46 + .../ClusteredRedisQueue/MatchServers.spec.ts | 44 + .../CopyEventEmitter.spec.ts} | 55 +- test/{IMQ.ts => unit/IMQ.spec.ts} | 51 +- .../IMessageQueue.spec.ts} | 15 +- test/unit/Profile/Decorator.spec.ts | 72 + test/unit/Profile/Profile.spec.ts | 297 + test/unit/RedisQueue/Cleanup.spec.ts | 35 + test/unit/RedisQueue/Connect.spec.ts | 35 + test/unit/RedisQueue/ProcessCleanup.spec.ts | 165 + .../RedisQueue/ProcessDelayed.spec.ts} | 40 +- test/unit/RedisQueue/Publish.spec.ts | 72 + .../RedisQueue/RedisQueue.spec.ts} | 201 +- test/unit/RedisQueue/Send.spec.ts | 57 + test/unit/RedisQueue/Unsubscribe.spec.ts | 40 + .../UDPClusterManager/DestroySocket.spec.ts} | 20 +- .../UDPClusterManager.spec.ts} | 72 +- test/{uuid.ts => unit/Uuid.spec.ts} | 37 +- tsconfig.json | 27 +- 70 files changed, 3569 insertions(+), 7654 deletions(-) delete mode 100644 eslint.config.mjs delete mode 100644 src/promisify.ts delete mode 100644 test/ClusteredRedisQueue.addServer.defaultInit.spec.ts delete mode 100644 test/ClusteredRedisQueue.addServer.noInit.spec.ts delete mode 100644 test/ClusteredRedisQueue.extra.spec.ts delete mode 100644 test/ClusteredRedisQueue.initialize.spec.ts delete mode 100644 test/ClusteredRedisQueue.matchServers.spec.ts delete mode 100644 test/ClusteredRedisQueue.ts delete mode 100644 test/RedisQueue.cleanup.catch.spec.ts delete mode 100644 test/RedisQueue.connect.fallbacks.spec.ts delete mode 100644 test/RedisQueue.processCleanup.clientsFilter.spec.ts delete mode 100644 test/RedisQueue.processCleanup.extra.spec.ts delete mode 100644 test/RedisQueue.processCleanup.multiscan.spec.ts delete mode 100644 test/RedisQueue.processCleanup.nullmatch.spec.ts delete mode 100644 test/RedisQueue.publish.spec.ts delete mode 100644 test/RedisQueue.send.extra.branches.spec.ts delete mode 100644 test/RedisQueue.send.worker.mode.spec.ts delete mode 100644 test/RedisQueue.unsubscribe.spec.ts delete mode 100644 test/UDPClusterManager.missing.branches.spec.ts create mode 100644 test/helpers/index.ts delete mode 100644 test/profile.decorator.branches.spec.ts delete mode 100644 test/profile.more.branches.spec.ts delete mode 100644 test/profile.rejection.spec.ts delete mode 100644 test/profile.ts delete mode 100644 test/promisify.ts rename test/{ => unit}/ClusterManager.spec.ts (55%) create mode 100644 test/unit/ClusteredRedisQueue/AddServer.spec.ts create mode 100644 test/unit/ClusteredRedisQueue/ClusteredRedisQueue.spec.ts create mode 100644 test/unit/ClusteredRedisQueue/Initialize.spec.ts create mode 100644 test/unit/ClusteredRedisQueue/MatchServers.spec.ts rename test/{copyEventEmitter.ts => unit/CopyEventEmitter.spec.ts} (83%) rename test/{IMQ.ts => unit/IMQ.spec.ts} (56%) rename test/{IMessageQueue.EventEmitter.spec.ts => unit/IMessageQueue.spec.ts} (82%) create mode 100644 test/unit/Profile/Decorator.spec.ts create mode 100644 test/unit/Profile/Profile.spec.ts create mode 100644 test/unit/RedisQueue/Cleanup.spec.ts create mode 100644 test/unit/RedisQueue/Connect.spec.ts create mode 100644 test/unit/RedisQueue/ProcessCleanup.spec.ts rename test/{RedisQueue.processDelayed.catch.spec.ts => unit/RedisQueue/ProcessDelayed.spec.ts} (52%) create mode 100644 test/unit/RedisQueue/Publish.spec.ts rename test/{RedisQueue.ts => unit/RedisQueue/RedisQueue.spec.ts} (61%) create mode 100644 test/unit/RedisQueue/Send.spec.ts create mode 100644 test/unit/RedisQueue/Unsubscribe.spec.ts rename test/{UDPClusterManager.destroySocket.spec.ts => unit/UDPClusterManager/DestroySocket.spec.ts} (72%) rename test/{UDPClusterManager.ts => unit/UDPClusterManager/UDPClusterManager.spec.ts} (68%) rename test/{uuid.ts => unit/Uuid.spec.ts} (73%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 67bcd75..06d757b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,8 +9,9 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - node-version: [lts/*] + node-version: [20.x, 22.x, 24.x] steps: - name: Checkout repository @@ -20,9 +21,22 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + cache: npm - name: Install dependencies - run: npm install + run: npm ci + + - name: Check formatting + run: npm run format:check + + - name: Lint + run: npm run lint - name: Run tests - run: npm test + run: npm run test-lcov + + - name: Upload coverage to Coveralls + if: matrix.node-version == '24.x' + uses: coverallsapp/github-action@v2 + with: + file: coverage/lcov.info 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/README.md b/README.md index b11ca5f..b311c25 100644 --- a/README.md +++ b/README.md @@ -147,12 +147,23 @@ predictable.* ## Running Unit Tests +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 This project is licensed under the GNU General Public License v3.0. diff --git a/benchmark/affinity.ts b/benchmark/affinity.ts index 06cb7e3..322b500 100644 --- a/benchmark/affinity.ts +++ b/benchmark/affinity.ts @@ -28,4 +28,4 @@ export function setAffinity(cpu: number) { if (os.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..f5f0cb5 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -24,81 +24,138 @@ import { execSync as exec } from 'child_process'; import * as os from 'os'; import * as fs from 'fs'; -import * as yargs from 'yargs'; +import { parseArgs } from 'node:util'; import { run } from './redis-test'; -import { resolve } from 'path'; +import { resolve } from 'path'; import { uuid, AnyJson } from '..'; import { setAffinity } from './affinity'; const cluster: any = require('cluster'); /** - * Command line args - * @type {yargs.Arguments} + * Command line options: canonical long name -> parseArgs config + description + * used to render `--help`. */ -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.') +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.', + }, +}; -.boolean(['h', 'z', 's']) - .argv; +/** + * Command line args + */ +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 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( + fs.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.'); + console.warn( + 'Given example message is invalid, ' + + 'proceeding test execution with with standard ' + + 'example message.', + ); } } @@ -134,7 +191,7 @@ function cpuAvg(i: number) { return { idle: totalIdle / cpus.length, - total: totalTick / cpus.length + total: totalTick / cpus.length, }; } @@ -150,9 +207,7 @@ interface MachineStats { * @param {any} memusage * @return {MachineStats} */ -function buildStats( - { metrics, memusage }: any -): MachineStats { +function buildStats({ metrics, memusage }: any): MachineStats { const stats: any[] = []; const memStats: any[] = ['System Memory Used, %']; @@ -165,12 +220,12 @@ 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 }; @@ -187,30 +242,33 @@ function buildChartConfig(id: string, stats: any[]) { 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( + (v: any, i: number) => + (((i * 100) / 1000).toFixed(1) + 's') as any, + ), 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, + }, }; } @@ -223,9 +281,11 @@ function buildChartConfig(id: string, stats: any[]) { * @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)) + return fmt.format( + Math.round( + data.reduce((prev, next) => prev + next[key], 0) / data.length, + ), + ); } /** @@ -234,13 +294,11 @@ 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 = ` @@ -267,44 +325,55 @@ function saveStats({ metrics, memusage }: any, data: any[]) {

Test Execution Information

CPU Usage

@@ -350,7 +419,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 +430,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 +440,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 +459,58 @@ 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 && + os.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)) + CPU_NAMES.map((name: string, i: number) => cpuAvg(i + 1)), ); memusage.push({ total: os.totalmem(), - free: os.freemem() + free: os.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 - })); + (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..3877fc0 100644 --- a/benchmark/redis-test.ts +++ b/benchmark/redis-test.ts @@ -26,61 +26,66 @@ import IMQ, { IJson, uuid, pack, - JsonObject, AnyJson, + JsonObject, + AnyJson, } from '../index'; /** * 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" - } - }] - } - } + }, + ], + }, + }, }; /** @@ -111,74 +116,76 @@ export async function run( msgDelay: number = 0, useGzip: boolean = false, safeDelivery: boolean = false, - jsonExample: AnyJson = JSON_EXAMPLE + jsonExample: AnyJson = JSON_EXAMPLE, ) { 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(); - - let count = 0; - const fmt = new Intl.NumberFormat( - 'en-US', { maximumSignificantDigits: 3 } - ); + 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(); - mq.on('message', () => count++); + let count = 0; + const fmt = new Intl.NumberFormat('en-US', { + maximumSignificantDigits: 3, + }); - 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) - ); - } + mq.on('message', () => count++); - const start = Date.now(); - - 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); - - console.log( - '%s is sent/received in %s ±10 ms', - fmt.format(count), - fmt.format(time) - ); + if (msgDelay) { console.log( - 'Round-trip ratio: %s messages/sec', - fmt.format(ratio) + 'Sending %s messages, using %s delay please, wait...', + fmt.format(steps), + fmt.format(msgDelay), ); + } else { console.log( - 'Message payload is: %s bytes', - fmt.format(bytesLen) + 'Sending %s messages, please, wait...', + fmt.format(steps), ); + } - mq.destroy().catch(); + const start = Date.now(); - clearInterval(interval); - resolve({ count, time, ratio, bytesLen, srcBytesLen }); + for (let i = 0; i < steps; i++) { + mq.send(queueName, jsonExample as JsonObject, msgDelay).catch(); } - }, 10); + + 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), + ); + + mq.destroy().catch(); + + 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..0f40730 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,5284 +9,1297 @@ "version": "2.0.26", "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" + "open": "^11.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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.14.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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "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", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@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", - "integrity": "sha512-TZ/s3ufaF+kEFPx9PqXKNd7OsPJoVsgOd59EA8XQLTRjJCsOdx5sEAOGhyrZ3Os78iaDKyGJWdQWyYWRg1Iyvg==", - "dev": true, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/sinonjs__fake-timers": "*" - } - }, - "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", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "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==", + "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": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" + "@shikijs/types": "3.23.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==", + "node_modules/@shikijs/types": { + "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": "ISC", + "license": "MIT", "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" + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" } }, - "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==", + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8.0.0" - } + "license": "MIT" }, - "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==", + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } }, - "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==", + "node_modules/@types/mock-require": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/mock-require/-/mock-require-3.0.0.tgz", + "integrity": "sha512-TZ/s3ufaF+kEFPx9PqXKNd7OsPJoVsgOd59EA8XQLTRjJCsOdx5sEAOGhyrZ3Os78iaDKyGJWdQWyYWRg1Iyvg==", "dev": true, - "license": "CC-BY-3.0" + "license": "MIT" }, - "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==", + "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": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" + "undici-types": "~7.16.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==", + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "dev": true, - "license": "CC0-1.0" + "license": "MIT" }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "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": "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" + "license": "Python-2.0" }, - "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==", + "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": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "run-applescript": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "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", + "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": ">=8" + "node": ">=0.10.0" } }, - "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, + "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-regex": "^5.0.1" + "ms": "^2.1.3" }, "engines": { - "node": ">=8" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "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==", + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "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" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "node_modules/default-browser-id": { "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==", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "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==", + "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", - "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" + "node": ">=12" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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/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/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", + "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": "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" - }, + "license": "BSD-2-Clause", "engines": { - "node": "*" + "node": ">=0.12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "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==", + "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", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } + "license": "ISC" }, - "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, + "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": { - "is-number": "^7.0.0" + "@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.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" + "node": ">=12.22.0" }, - "peerDependencies": { - "typescript": ">=4.8.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" } }, - "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==", + "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", - "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" + "is-docker": "cli.js" }, - "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" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "engines": { - "node": ">= 0.8.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", "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": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "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-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" + "is-docker": "^3.0.0" }, "bin": { - "typedoc": "bin/typedoc" + "is-inside-container": "cli.js" }, "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" + "node": ">=14.16" }, - "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==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "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==", + "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": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" + "type": "github", + "url": "https://github.com/sponsors/puzrin" }, { "type": "github", - "url": "https://github.com/sponsors/ai" + "url": "https://github.com/sponsors/markdown-it" } ], "license": "MIT", "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "uc.micro": "^2.0.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==", + "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": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } + "license": "MIT" }, - "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==", + "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, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", + "dependencies": { + "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": { - "uuid": "dist/bin/uuid" + "markdown-it": "bin/markdown-it.mjs" } }, - "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==", + "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/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "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": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" + "get-caller-file": "^1.0.2", + "normalize-path": "^2.1.1" }, "engines": { - "node": ">= 8" + "node": ">=4.3.0" } }, - "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==", - "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/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "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/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/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "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": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" }, "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "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/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==", + "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": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, "engines": { - "node": ">=8" + "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/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/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", "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": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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/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": { - "ansi-regex": "^5.0.1" - }, "engines": { - "node": ">=8" + "node": ">=6" } }, - "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==", - "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": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=4" } }, - "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==", - "dev": true, - "license": "ISC" - }, - "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==", - "dev": true, - "license": "ISC", + "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": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" } }, - "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/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/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/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", - "dependencies": { - "is-wsl": "^3.1.0" - }, "engines": { "node": ">=18" }, @@ -5294,172 +1307,144 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "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/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/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/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", - "bin": { - "yaml": "bin.mjs" - }, + "license": "MIT", "engines": { - "node": ">= 14.6" + "node": "^20.0.0 || >=22.0.0" } }, - "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "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": { - "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" + "@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": "^20.19.0 || ^22.12.0 || >=23" + "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 || 6.0.x" } }, - "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/typedoc/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": "ISC", + "license": "MIT", "engines": { - "node": ">=12" + "node": "18 || 20 || >=22" } }, - "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/typedoc/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": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" } }, - "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==", + "node_modules/typedoc/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": { + "brace-expansion": "^5.0.5" + }, "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "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==", + "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": "MIT", - "engines": { - "node": ">=10" + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=14.17" } }, - "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/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/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/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": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } + "license": "MIT" }, - "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/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "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/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "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==", - "dev": true, - "license": "MIT", - "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..558b4f4 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,19 @@ ], "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 }))\"", + "build": "tsc", + "prepare": "tsc", + "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": "tsc && node --test --test-timeout=15000 \"test/**/*.spec.js\"", + "test-coverage": "tsc && node --test --experimental-test-coverage --test-timeout=15000 \"test/**/*.spec.js\"", + "test-lcov": "tsc && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-timeout=15000 \"test/**/*.spec.js\"", "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", @@ -38,36 +41,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" + "open": "^11.0.0", + "oxfmt": "0.57.0", + "oxlint": "1.72.0", + "typedoc": "^0.28.20", + "typescript": "^6.0.3" }, "main": "index.js", "typescript": { diff --git a/src/ClusterManager.ts b/src/ClusterManager.ts index 86bbab5..00de6de 100644 --- a/src/ClusterManager.ts +++ b/src/ClusterManager.ts @@ -25,11 +25,9 @@ import { IMessageQueueConnection, IServerInput } from './IMessageQueue'; import { uuid } from './uuid'; 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,10 +40,9 @@ export abstract class ClusterManager { protected constructor() {} public init(cluster: ICluster): InitializedCluster { - const initializedCluster = Object.assign( - cluster, - { id: uuid() }, - ) as InitializedCluster; + const initializedCluster = Object.assign(cluster, { + id: uuid(), + }) as InitializedCluster; this.clusters.push(initializedCluster); @@ -67,9 +64,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..6240a2f 100644 --- a/src/ClusteredRedisQueue.ts +++ b/src/ClusteredRedisQueue.ts @@ -55,9 +55,9 @@ interface ClusterState { * 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 * @@ -154,13 +154,14 @@ export class ClusteredRedisQueue implements IMessageQueue, 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 +173,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 +193,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,8 +208,10 @@ 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...', + ); } /** @@ -226,17 +233,13 @@ 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 await new Promise(resolve => + this.clusterEmitter.once('initialized', async ({ imq }) => { + resolve( + await imq.send(toQueue, message, delay, errorHandler), + ); + }), + ); } if (this.currentQueue >= this.imqLength) { @@ -261,8 +264,10 @@ 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; @@ -283,8 +288,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,9 +308,9 @@ 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}`, + ); } } @@ -329,136 +336,134 @@ 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. + * + * @param {K} method + * @param {any[]} args + * @return {unknown[]} + */ + 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); - } + // noinspection JSUnusedGlobalSymbols + 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; + // istanbul ignore next + 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 @@ -508,7 +513,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 +525,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 +537,14 @@ 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(); } 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, @@ -594,12 +596,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 +613,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 +622,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..032136f 100644 --- a/src/IMessageQueue.ts +++ b/src/IMessageQueue.ts @@ -30,8 +30,14 @@ export { EventEmitter } from '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 @@ -200,7 +206,7 @@ export interface IMQOptions extends Partial { * * @type {string} */ - cleanupFilter: string, + cleanupFilter: string; /** * Message queue vendor @@ -296,8 +302,8 @@ export interface IMQOptions extends Partial { } export interface EventMap { - message: [data: any, id: string, from: string], - error: [error: Error, eventName: string], + message: [data: any, id: string, from: string]; + error: [error: Error, eventName: string]; } export type IMessageQueueConstructor = new ( @@ -387,8 +393,12 @@ export interface IMessageQueue extends EventEmitter { * internal error occurs during message send execution. * @returns {Promise} - 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 diff --git a/src/RedisQueue.ts b/src/RedisQueue.ts index 7b54c27..a3da54d 100644 --- a/src/RedisQueue.ts +++ b/src/RedisQueue.ts @@ -116,9 +116,10 @@ const IMQ_REDIS_MAX_LISTENERS_LIMIT = +( * Class RedisQueue * Implements simple messaging queue over redis. */ -export class RedisQueue extends EventEmitter - implements IMessageQueue { - +export class RedisQueue + extends EventEmitter + implements IMessageQueue +{ [name: string]: any; /** @@ -205,8 +206,8 @@ export class RedisQueue extends EventEmitter * Internal per-channel reconnection state */ private reconnectTimers: Partial> = {}; - private reconnectAttempts: Partial> - = {}; + private reconnectAttempts: Partial> = + {}; private reconnecting: Partial> = {}; /** @@ -220,21 +221,21 @@ export class RedisQueue extends EventEmitter * @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]) ' + + '"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]) ' + + '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', }, @@ -269,10 +270,7 @@ 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; @@ -280,19 +278,22 @@ export class RedisQueue extends EventEmitter /* 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 }`); + 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}`); } } @@ -311,7 +312,7 @@ export class RedisQueue extends EventEmitter // istanbul ignore next if (!channel) { throw new TypeError( - `${ channel }: No subscription channel name provided!`, + `${channel}: No subscription channel name provided!`, ); } @@ -319,7 +320,8 @@ export class RedisQueue extends EventEmitter 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; @@ -336,13 +338,14 @@ export class RedisQueue extends EventEmitter handler(JSON.parse(message) as unknown as JsonObject); } - this.verbose(`Received message from ${ - ch } channel, data: ${ - JSON.stringify(message) }`, + this.verbose( + `Received message from ${ch} channel, data: ${JSON.stringify( + message, + )}`, ); }); - this.verbose(`Subscribed to ${ channel } channel`); + this.verbose(`Subscribed to ${channel} channel`); } /** @@ -360,15 +363,16 @@ 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(); this.subscription.disconnect(false); } catch (error) { - this.verbose(`Unsubscribe error: ${ error }`); + this.verbose(`Unsubscribe error: ${error}`); } } @@ -395,14 +399,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 +412,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) { @@ -446,8 +445,8 @@ export class RedisQueue extends EventEmitter let exitCode = 0; setTimeout(() => { - this.verbose(`Shutting down after ${ - IMQ_SHUTDOWN_TIMEOUT } timeout`, + this.verbose( + `Shutting down after ${IMQ_SHUTDOWN_TIMEOUT} timeout`, ); process.exit(exitCode); }, IMQ_SHUTDOWN_TIMEOUT); @@ -512,7 +511,7 @@ export class RedisQueue extends EventEmitter const cb = (error: any, op: string) => { // istanbul ignore next if (error) { - this.verbose(`Writer ${ op } error: ${ error }`); + this.verbose(`Writer ${op} error: ${error}`); if (errorHandler) { errorHandler(error as unknown as Error); @@ -521,7 +520,10 @@ export class RedisQueue extends EventEmitter }; if (delay) { - this.writer.zadd(`${key}:delayed`, Date.now() + delay, packet, + this.writer.zadd( + `${key}:delayed`, + Date.now() + delay, + packet, (err: any) => { // istanbul ignore next if (err) { @@ -530,17 +532,25 @@ export class RedisQueue extends EventEmitter 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: any) => { + // istanbul ignore next + if (err) { + cb(err, 'SET'); + + return; + } + }, + ) + .catch((err: any) => cb(err, 'SET')); + }, + ); } else { this.writer.lpush(key, packet, (err: any) => { // istanbul ignore next @@ -613,7 +623,7 @@ 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!'); @@ -621,8 +631,9 @@ export class RedisQueue extends EventEmitter // 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, ); } @@ -721,7 +732,7 @@ export class RedisQueue extends EventEmitter * @returns {string} */ private get lockKey(): string { - return `${ this.options.prefix }:watch:lock`; + return `${this.options.prefix}:watch:lock`; } /** @@ -779,13 +790,16 @@ export class RedisQueue extends EventEmitter if (client) { try { client.removeAllListeners(); - client.quit().then(() => { - client.disconnect(false); - }).catch(e => { - this.verbose(`Error quitting ${ channel }: ${ e }`); - }); + client + .quit() + .then(() => { + client.disconnect(false); + }) + .catch(e => { + this.verbose(`Error quitting ${channel}: ${e}`); + }); } catch (error) { - this.verbose(`Error destroying ${ channel }: ${ error }`); + this.verbose(`Error destroying ${channel}: ${error}`); } } } @@ -804,7 +818,7 @@ export class RedisQueue extends EventEmitter options: IMQOptions, context: RedisQueue = this, ): Promise { - this.verbose(`Connecting to ${ channel } channel...`); + this.verbose(`Connecting to ${channel} channel...`); // istanbul ignore next if (context[channel]) { @@ -846,7 +860,7 @@ export class RedisQueue extends EventEmitter 'close', ]) { redis.on(event, () => { - context.verbose(`Redis Event fired: ${ event }`); + context.verbose(`Redis Event fired: ${event}`); }); } @@ -858,13 +872,22 @@ export class RedisQueue extends EventEmitter this.logger.info( '%s: %s channel connected, host %s, pid %s', - context.name, channel, this.redisKey, process.pid, + context.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; } return context[channel]; @@ -905,8 +928,11 @@ export class RedisQueue extends EventEmitter this.reconnecting[channel] = true; this.reconnectAttempts[channel] = attempts; - this.verbose(`Scheduling ${ channel } reconnect in ${ - delay } ms (attempt ${ attempts })`); + this.verbose( + `Scheduling ${channel} reconnect in ${ + delay + } ms (attempt ${attempts})`, + ); if (this.reconnectTimers[channel]) { clearTimeout(this.reconnectTimers[channel] as any); @@ -948,10 +974,10 @@ export class RedisQueue extends EventEmitter this.reconnectTimers[channel] = undefined; } - this.verbose(`Reconnected ${ channel } channel`); + this.verbose(`Reconnected ${channel} channel`); } catch (err) { this.reconnecting[channel] = false; - this.verbose(`Reconnect ${ channel } failed: ${ err }`); + this.verbose(`Reconnect ${channel} failed: ${err}`); this.scheduleReconnect(channel); } }, delay); @@ -971,9 +997,9 @@ export class RedisQueue extends EventEmitter prefix: string, name: RedisConnectionChannel, ): string { - const uniqueSuffix = `pid:${ process.pid }:host:${ os.hostname() }`; + const uniqueSuffix = `pid:${process.pid}:host:${os.hostname()}`; - return`${ prefix }:${ contextName }:${ name }:${ uniqueSuffix }`; + return `${prefix}:${contextName}:${name}:${uniqueSuffix}`; } /** @@ -989,8 +1015,8 @@ export class RedisQueue extends EventEmitter 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; @@ -998,8 +1024,8 @@ export class RedisQueue extends EventEmitter this.logger.error( `${context.name}: error connecting redis host ${ - this.redisKey} on ${ - channel}, pid ${process.pid}:`, + this.redisKey + } on ${channel}, pid ${process.pid}:`, error, ); @@ -1010,7 +1036,7 @@ export class RedisQueue extends EventEmitter ) { this.scheduleReconnect(channel); } - }); + }; } /** @@ -1025,21 +1051,24 @@ export class RedisQueue extends EventEmitter context: RedisQueue, channel: RedisConnectionChannel, ): (...args: any[]) => any { - this.verbose(`Redis ${ channel } is closing...`); + 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, + context.name, + channel, + this.redisKey, + process.pid, ); if (!this.destroyed) { this.scheduleReconnect(channel); } - }); + }; } /** @@ -1111,7 +1140,10 @@ export class RedisQueue extends EventEmitter if (this.scripts.moveDelayed.checksum) { await this.writer.evalsha( this.scripts.moveDelayed.checksum, - 2, `${ key }:delayed`, key, Date.now(), + 2, + `${key}:delayed`, + key, + Date.now(), ); } } catch (err) { @@ -1139,14 +1171,14 @@ export class RedisQueue extends EventEmitter const data = await this.writer.scan( cursor, 'MATCH', - `${ this.options.prefix }:*:worker:*`, + `${this.options.prefix}:*:worker:*`, 'COUNT', '1000', ); cursor = data.shift() as string; - const keys = data.shift() as string[] || []; + const keys = (data.shift() as string[]) || []; await this.processKeys(keys, now); @@ -1180,9 +1212,11 @@ export class RedisQueue extends EventEmitter 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(':'); @@ -1191,7 +1225,7 @@ export class RedisQueue extends EventEmitter continue; } - await this.writer.rpoplpush(key, `${ kp.shift() }:${ kp.shift() }`); + await this.writer.rpoplpush(key, `${kp.shift()}:${kp.shift()}`); } } @@ -1215,11 +1249,7 @@ 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 as unknown as Error); } } @@ -1249,17 +1279,15 @@ export class RedisQueue extends EventEmitter } 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 => { + this.emitError( + 'OnConfig', + 'events config error', + err as unknown as Error, + ); + }); } catch (err) { this.emitError( 'OnConfig', @@ -1272,12 +1300,14 @@ export class RedisQueue extends EventEmitter '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 + .psubscribe( + '__keyevent@0__:expired', + `${this.options.prefix}:delayed:*`, + ) + .catch(err => { + this.verbose(`Error subscribing to watcher channel: ${err}`); + }); // watch for expired unhandled safe queues if (!this.safeCheckInterval) { @@ -1287,7 +1317,7 @@ export class RedisQueue extends EventEmitter if (!this.writer) { this.cleanSafeCheckInterval(); - return ; + return; } if (this.options.safeDelivery) { @@ -1295,7 +1325,9 @@ export class RedisQueue extends EventEmitter } await this.processCleanup(); - }) as unknown as () => void, this.options.safeDeliveryTtl); + }) as unknown as () => void, + this.options.safeDeliveryTtl, + ); } } @@ -1319,40 +1351,42 @@ export class RedisQueue extends EventEmitter } const filter: RegExp = new RegExp( - this.options.prefix + ':' + - (this.options.cleanupFilter || '*').replace(/\*/g, '.*'), + this.options.prefix + + ':' + + (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, ); const keysToRemove: string[] = []; let cursor = '0'; - this.verbose(`Found connected keys: ${ - connectedKeys.map(k => `"${ k }"`).join(', ') - }`); + this.verbose( + `Found connected keys: ${connectedKeys + .map(k => `"${k}"`) + .join(', ')}`, + ); while (true) { const data = await this.writer.scan( cursor, 'MATCH', - `${ - this.options.prefix}:${ + `${this.options.prefix}:${ this.options.cleanupFilter || '*' }`, 'COUNT', @@ -1361,11 +1395,12 @@ export class RedisQueue extends EventEmitter cursor = data.shift() as string; - const keys = data.shift() as string[] || []; + const keys = (data.shift() as string[]) || []; keysToRemove.push( ...keys.filter( - key => key !== this.lockKey && + key => + key !== this.lockKey && connectedKeys.every( connectedKey => key.indexOf(connectedKey) === -1, @@ -1374,16 +1409,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); @@ -1413,8 +1450,10 @@ export class RedisQueue extends EventEmitter } } catch (err) { // istanbul ignore next - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - if (err.message.match(/Stream connection ended/)) { + if ( + err instanceof Error && + err.message.match(/Stream connection ended/) + ) { break; } @@ -1442,8 +1481,8 @@ export class RedisQueue extends EventEmitter const key = this.key; while (true) { - const expire: number = Date.now() + - Number(this.options.safeDeliveryTtl); + const expire: number = + Date.now() + Number(this.options.safeDeliveryTtl); const workerKey = `${key}:worker:${uuid()}:${expire}`; if (!this.reader || !this.writer) { @@ -1458,9 +1497,7 @@ export class RedisQueue extends EventEmitter break; } - const msgArr: any = await this.writer.lrange( - workerKey, -1, 1, - ); + const msgArr: any = await this.writer.lrange(workerKey, -1, 1); if (!msgArr || msgArr?.length !== 1) { // noinspection ExceptionCaughtLocallyJS @@ -1471,8 +1508,9 @@ export class RedisQueue extends EventEmitter const [msg] = msgArr as any[]; 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) { // istanbul ignore next @@ -1494,7 +1532,8 @@ export class RedisQueue extends EventEmitter 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; @@ -1564,13 +1603,16 @@ export class RedisQueue extends EventEmitter private emitError(eventName: string, message: string, err: Error): void { this.emit('error', err, 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}`, + ); } /** @@ -1591,10 +1633,10 @@ export class RedisQueue extends EventEmitter this.scripts[script].checksum = checksum; - const scriptExists = await this.writer.script( + const scriptExists = (await this.writer.script( 'EXISTS', checksum, - ) as number[]; + )) as number[]; const loaded = (scriptExists || []).shift(); if (!loaded) { @@ -1631,11 +1673,11 @@ export class RedisQueue extends EventEmitter resolve: (...args: any[]) => void, reject: (...args: any[]) => void, ): () => Promise { - return (async () => { + return async () => { try { - const noWatcher = !await this.watcherCount(); + const noWatcher = !(await this.watcherCount()); - if (await this.isLocked() && noWatcher) { + if ((await this.isLocked()) && noWatcher) { await this.unlock(); await this.ownWatch(); } @@ -1644,7 +1686,7 @@ export class RedisQueue extends EventEmitter } catch (err) { reject(err); } - }); + }; } /** @@ -1655,13 +1697,10 @@ export class RedisQueue extends EventEmitter */ // istanbul ignore next private async initWatcher(): Promise { - return new Promise( - (async ( - resolve: (...args: any[]) => void, - reject: (...args: any[]) => void, - ): Promise => { + return new Promise((resolve, reject) => { + void (async (): Promise => { try { - if (!await this.watcherCount()) { + if (!(await this.watcherCount())) { this.verbose('Initializing watcher...'); await this.ownWatch(); @@ -1683,17 +1722,15 @@ export class RedisQueue extends EventEmitter } } catch (err) { this.logger.error( - `${ this.name }: error initializing watcher, pid ${ - process.pid } on redis host ${ this.redisKey }`, + `${this.name}: error initializing watcher, pid ${ + process.pid + } on redis host ${this.redisKey}`, err, ); reject(err); } - }) as unknown as ( - resolve: (...args: any[]) => void, - reject: (...args: any[]) => void, - ) => void, - ); + })(); + }); } } diff --git a/src/UDPClusterManager.ts b/src/UDPClusterManager.ts index 73bb159..8379e5a 100644 --- a/src/UDPClusterManager.ts +++ b/src/UDPClusterManager.ts @@ -87,15 +87,15 @@ export class UDPClusterManager extends ClusterManager { private static workers: Record = {}; public static sockets: Record = {}; private readonly options: UDPClusterManagerOptions; - private workerKey: string; - private worker: Worker; + private workerKey!: string; + private worker!: Worker; constructor(options?: Partial) { super(); this.options = { ...DEFAULT_UDP_CLUSTER_MANAGER_OPTIONS, - ...options || {}, + ...options, }; this.startWorkerListener(); @@ -108,16 +108,18 @@ export class UDPClusterManager extends ClusterManager { private static async free(): Promise { 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], + ), + ), ); } private startWorkerListener(): void { - this.workerKey = `${ this.options.address }:${ this.options.port }`; + this.workerKey = `${this.options.address}:${this.options.port}`; if (UDPClusterManager.workers[this.workerKey]) { this.worker = UDPClusterManager.workers[this.workerKey]; @@ -129,7 +131,7 @@ export class UDPClusterManager extends ClusterManager { workerData: this.options, }); this.worker.on('message', message => { - const [className, method] = message.type?.split(':'); + const [className, method] = (message.type ?? '').split(':'); if (className !== 'cluster') { return; @@ -138,16 +140,21 @@ export class UDPClusterManager extends ClusterManager { return this.anyCluster(cluster => { if (method === 'add') { try { - const existing = typeof (cluster as any).find === 'function' - ? (cluster as any).find(message.server, true) - : undefined; + const existing = + typeof (cluster as any).find === 'function' + ? (cluster as any).find(message.server, true) + : undefined; if (existing) { return; } - } catch {/* ignore */} + } catch { + /* ignore */ + } } - const clusterMethod = (cluster as any)[method as keyof ICluster]; + const clusterMethod = (cluster as any)[ + method as keyof ICluster + ]; if (!clusterMethod) { return; @@ -164,29 +171,31 @@ export class UDPClusterManager extends ClusterManager { await UDPClusterManager.destroyWorker(this.workerKey, this.worker); } - public static async destroySocket(key: string, socket?: any): Promise { + public static async destroySocket( + key: string, + socket?: any, + ): Promise { if (!socket) { return; } - try { - if (typeof socket.removeAllListeners === 'function') { - socket.removeAllListeners(); - } - } catch (e) { - throw e; + if (typeof socket.removeAllListeners === 'function') { + socket.removeAllListeners(); } if (typeof socket.close !== 'function') { return; } - await new Promise((resolve) => { + await new Promise(resolve => { socket.close(() => { if (typeof socket.unref === 'function') { socket.unref(); } - if (UDPClusterManager.sockets && key in UDPClusterManager.sockets) { + if ( + UDPClusterManager.sockets && + key in UDPClusterManager.sockets + ) { delete UDPClusterManager.sockets[key]; } resolve(); @@ -209,7 +218,7 @@ export class UDPClusterManager extends ClusterManager { }, 5000); worker.postMessage({ type: 'stop' }); - worker.once('message', (message) => { + worker.once('message', message => { if (message.type === 'stopped') { clearTimeout(timeout); worker.terminate(); diff --git a/src/UDPWorker.ts b/src/UDPWorker.ts index da83b06..399abb8 100644 --- a/src/UDPWorker.ts +++ b/src/UDPWorker.ts @@ -64,9 +64,8 @@ class UDPWorker { reuseAddr: true, reusePort: true, }).bind(this.options.port, this.selectNetworkInterface()); - this.socket.on( - 'message', - message => this.processMessage(this.parseMessage(message)), + this.socket.on('message', message => + this.processMessage(this.parseMessage(message)), ); } @@ -126,17 +125,23 @@ class UDPWorker { this.servers.set(key, stamp); - const t: any = setTimeout(() => setImmediate(() => { - if (this.servers.get(key) === stamp) { - this.removeServer(message); - } - }), effectiveTimeout); + 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 */} + } catch { + /* ignore */ + } } private processMessage(message: Message): void { @@ -151,13 +156,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 +173,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, ''), ); @@ -183,13 +189,9 @@ class UDPWorker { } private parseMessage(input: Buffer): Message { - const [ - name, - id, - type, - address = '', - timeout = '0', - ] = input.toString().split('\t'); + const [name, id, type, address = '', timeout = '0'] = input + .toString() + .split('\t'); const [host, port] = address.split(':'); return { diff --git a/src/copyEventEmitter.ts b/src/copyEventEmitter.ts index 2d4559f..20721b7 100644 --- a/src/copyEventEmitter.ts +++ b/src/copyEventEmitter.ts @@ -40,8 +40,8 @@ export function copyEventEmitter( for (const originalListener of listeners) { if (util.inspect(originalListener).includes('onceWrapper')) { - const realListener = originalListener?.listener - || originalListener; + const realListener = + originalListener?.listener || originalListener; target.once(event, realListener); } else { diff --git a/src/index.ts b/src/index.ts index c5c1b5f..2a271bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,7 +40,6 @@ export function buildOptions( export * from './IMQMode'; export * from './profile'; export * from './uuid'; -export * from './promisify'; export * from './redis'; export * from './IMessageQueue'; export * from './RedisQueue'; diff --git a/src/profile.ts b/src/profile.ts index 2a6c783..5b08e54 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -21,7 +21,6 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import 'reflect-metadata'; import { ILogger } from '.'; export enum LogLevel { @@ -100,7 +99,7 @@ export const IMQ_LOG_ARGS = !!+(process.env.IMQ_LOG_ARGS || 0); * @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 { /** @@ -159,15 +158,13 @@ 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 @@ -193,21 +190,25 @@ export function logDebugInfo({ const cache: any[] = []; 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: any) => { + if (typeof value === 'object' && value !== null) { + if (~cache.indexOf(value)) { + try { + return JSON.parse(JSON.stringify(value)); + } catch (error) { + return; + } } - } - cache.push(value); - } + cache.push(value); + } - return value; - }, 2); + return value; + }, + 2, + ); } catch (err) { logger.error(err); } @@ -245,11 +246,15 @@ export function logDebugInfo({ * 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, +): ( + original: (this: This, ...args: Args) => Return, + context: ClassMethodDecoratorContext< + This, + (this: This, ...args: Args) => Return + >, +) => (this: This, ...args: Args) => Return { options = Object.assign({}, DEFAULT_OPTIONS, options); const { enableDebugTime, enableDebugArgs, logLevel } = options; @@ -264,47 +269,52 @@ 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]; + return function decorator( + original: (this: This, ...args: Args) => Return, + context: ClassMethodDecoratorContext< + This, + (this: This, ...args: Args) => Return + >, + ): (this: This, ...args: Args) => Return { + const methodName = String(context.name); - descriptor.value = function(...args: any[]) { + return function wrapper(this: This, ...args: Args): Return { if (!(debugTime || debugArgs)) { - return original.apply(this || target, args); + return original.apply(this, args); } + const self = this as any; /* 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 = self + ? typeof self === 'function' + ? self.name // static + : self.constructor && self.constructor.name // dynamic + : ''; + 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: self && self.logger, methodName, start, }; /* istanbul ignore next */ - if (result && typeof result.then === 'function') { + if (result && typeof (result as any).then === 'function') { // async call detected - result.then((res: any) => { - logDebugInfo(debugOptions); + (result as any) + .then((res: any) => { + logDebugInfo(debugOptions); - return res; - }).catch(() => { - logDebugInfo(debugOptions); - }); + return res; + }) + .catch(() => { + logDebugInfo(debugOptions); + }); return result; } 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/uuid.ts b/src/uuid.ts index 18fe85f..0006e14 100644 --- a/src/uuid.ts +++ b/src/uuid.ts @@ -23,43 +23,17 @@ * 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); +import { randomUUID } from 'crypto'; /** - * Generates and returns Unified Unique Identifier + * Generates and returns Unified Unique Identifier (RFC 4122 v4). + * + * Uses the cryptographically strong native generator, which is both faster + * and collision-safe under high load compared to a Math.random() based + * implementation. * * @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]; + return randomUUID(); } 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.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.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.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/helpers/index.ts b/test/helpers/index.ts new file mode 100644 index 0000000..38af796 --- /dev/null +++ b/test/helpers/index.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 '../..'; + +/** + * 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..4a01c3e 100644 --- a/test/mocks/dgram.ts +++ b/test/mocks/dgram.ts @@ -25,7 +25,7 @@ import mock = require('mock-require'); import { EventEmitter } from 'events'; class Socket extends EventEmitter { - public bindArgs: any[]; + public bindArgs: any[] = []; constructor() { super(); 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/os.ts b/test/mocks/os.ts index be7ee59..d62155c 100644 --- a/test/mocks/os.ts +++ b/test/mocks/os.ts @@ -37,4 +37,4 @@ export const networkInterfaces = () => { }; }; -mock('os', Object.assign(os, { networkInterfaces })); +mock('os', { ...os, networkInterfaces }); diff --git a/test/mocks/redis.ts b/test/mocks/redis.ts index d652392..44fee50 100644 --- a/test/mocks/redis.ts +++ b/test/mocks/redis.ts @@ -21,7 +21,7 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import * as mock from 'mock-require'; +import mock from 'mock-require'; import { EventEmitter } from 'events'; import * as crypto from 'crypto'; @@ -81,7 +81,7 @@ export class RedisClientMock extends EventEmitter { 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; @@ -108,12 +108,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,23 +131,28 @@ 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'; } @@ -157,8 +165,8 @@ export class RedisClientMock extends EventEmitter { 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; @@ -189,13 +197,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)]; } @@ -217,8 +229,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; } @@ -275,7 +286,7 @@ export class RedisClientMock extends EventEmitter { 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); @@ -321,6 +332,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/ClusterManager.spec.ts b/test/unit/ClusterManager.spec.ts similarity index 55% rename from test/ClusterManager.spec.ts rename to test/unit/ClusterManager.spec.ts index 42b86df..b86ffa7 100644 --- a/test/ClusterManager.spec.ts +++ b/test/unit/ClusterManager.spec.ts @@ -4,51 +4,57 @@ * 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'; +import '../mocks'; +import { describe, it, afterEach, mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { ClusterManager, InitializedCluster } from '../../src/ClusterManager'; class TestClusterManager extends ClusterManager { public destroyed = false; - public constructor() { super(); } + 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), + 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'); + assert.equal((cm as any).clusters.length, 1); + const spy = mock.method(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; + 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), + add: () => ({}) as any, remove: () => undefined, find: () => undefined, }); - const spy = sinon.spy(cm, 'destroy'); + const spy = mock.method(cm, 'destroy'); await cm.remove(cluster.id, false); - expect(spy.called).to.be.false; - expect((cm as any).clusters.length).to.equal(0); + assert.equal(spy.mock.callCount() > 0, false); + assert.equal((cm as any).clusters.length, 0); }); }); diff --git a/test/unit/ClusteredRedisQueue/AddServer.spec.ts b/test/unit/ClusteredRedisQueue/AddServer.spec.ts new file mode 100644 index 0000000..43b02ed --- /dev/null +++ b/test/unit/ClusteredRedisQueue/AddServer.spec.ts @@ -0,0 +1,71 @@ +/*! + * ClusteredRedisQueue.addServerWithQueueInitializing tests + * (default initializeQueue param branch + initializeQueue=false branch) + */ +import '../../mocks'; +import { describe, it } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { ClusteredRedisQueue } from '../../../src'; +import { ClusterManager } from '../../../src/ClusterManager'; + +const server = { host: '127.0.0.1', port: 6380 }; + +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(); + }); +}); diff --git a/test/unit/ClusteredRedisQueue/ClusteredRedisQueue.spec.ts b/test/unit/ClusteredRedisQueue/ClusteredRedisQueue.spec.ts new file mode 100644 index 0000000..a9fdbdc --- /dev/null +++ b/test/unit/ClusteredRedisQueue/ClusteredRedisQueue.spec.ts @@ -0,0 +1,398 @@ +/*! + * ClusteredRedisQueue Unit Tests (core behavior + EventEmitter proxy methods) + * + * 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 * as assert from 'node:assert/strict'; +import { ClusteredRedisQueue } 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, + }, + ], +}; + +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); + }); +}); diff --git a/test/unit/ClusteredRedisQueue/Initialize.spec.ts b/test/unit/ClusteredRedisQueue/Initialize.spec.ts new file mode 100644 index 0000000..7bcfede --- /dev/null +++ b/test/unit/ClusteredRedisQueue/Initialize.spec.ts @@ -0,0 +1,46 @@ +/*! + * Additional tests for ClusteredRedisQueue.initializeQueue branches + */ +import '../../mocks'; +import { describe, it, mock, Mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +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: 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(); + }); +}); diff --git a/test/unit/ClusteredRedisQueue/MatchServers.spec.ts b/test/unit/ClusteredRedisQueue/MatchServers.spec.ts new file mode 100644 index 0000000..8ea0681 --- /dev/null +++ b/test/unit/ClusteredRedisQueue/MatchServers.spec.ts @@ -0,0 +1,44 @@ +/*! + * Tests for ClusteredRedisQueue.matchServers combinations + */ +import '../../mocks'; +import { describe, it } from 'node:test'; +import * as assert from 'node:assert/strict'; +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', () => { + 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, + ); + }); +}); diff --git a/test/copyEventEmitter.ts b/test/unit/CopyEventEmitter.spec.ts similarity index 83% rename from test/copyEventEmitter.ts rename to test/unit/CopyEventEmitter.spec.ts index 99a50de..3f7641d 100644 --- a/test/copyEventEmitter.ts +++ b/test/unit/CopyEventEmitter.spec.ts @@ -19,12 +19,13 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import './mocks'; +import '../mocks'; +import { describe, it } from 'node:test'; +import * as assert from 'node:assert/strict'; import { EventEmitter } from 'events'; -import { expect } from 'chai'; -import { copyEventEmitter } from '../src'; +import { copyEventEmitter } from '../../src'; -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,9 +83,9 @@ 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 @@ -104,7 +105,7 @@ describe('copyEventEmitter()', function() { // Restore original inspect require('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,14 +130,14 @@ 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) => { @@ -152,14 +153,14 @@ describe('copyEventEmitter()', function() { // Restore original inspect require('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) => { @@ -175,7 +176,7 @@ describe('copyEventEmitter()', function() { // Restore original inspect require('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,8 +185,10 @@ 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; @@ -203,10 +206,10 @@ describe('copyEventEmitter()', function() { require('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,7 +221,9 @@ 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; @@ -234,8 +239,8 @@ describe('copyEventEmitter()', function() { // Restore original inspect require('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/IMQ.ts b/test/unit/IMQ.spec.ts similarity index 56% rename from test/IMQ.ts rename to test/unit/IMQ.spec.ts index a26abbe..be59d42 100644 --- a/test/IMQ.ts +++ b/test/unit/IMQ.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 * as 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/IMessageQueue.EventEmitter.spec.ts b/test/unit/IMessageQueue.spec.ts similarity index 82% rename from test/IMessageQueue.EventEmitter.spec.ts rename to test/unit/IMessageQueue.spec.ts index b1fe92f..a1a7eb7 100644 --- a/test/IMessageQueue.EventEmitter.spec.ts +++ b/test/unit/IMessageQueue.spec.ts @@ -19,9 +19,10 @@ * 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 '../mocks'; +import { describe, it } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { EventEmitter as IMQEventEmitter } from '../../src'; import { EventEmitter as NodeEventEmitter } from 'events'; // This test ensures the re-exported EventEmitter from IMessageQueue.ts is exercised @@ -29,13 +30,15 @@ import { EventEmitter as NodeEventEmitter } from 'events'; 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/Profile/Decorator.spec.ts b/test/unit/Profile/Decorator.spec.ts new file mode 100644 index 0000000..047da47 --- /dev/null +++ b/test/unit/Profile/Decorator.spec.ts @@ -0,0 +1,72 @@ +/*! + * profile decorator branch coverage & async rejection path unit tests + */ +import '../../mocks'; +import { describe, it, mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +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); + 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 (e) { + // expected + } + // allow microtask queue + await new Promise(res => setTimeout(res, 0)); + assert.ok(logger.info.mock.callCount() > 0); + }); +}); diff --git a/test/unit/Profile/Profile.spec.ts b/test/unit/Profile/Profile.spec.ts new file mode 100644 index 0000000..7fcfbdc --- /dev/null +++ b/test/unit/Profile/Profile.spec.ts @@ -0,0 +1,297 @@ +/*! + * profile() Function Unit Tests (merged with additional branch coverage) + * + * 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 * as 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 (err) { + 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; + let warn: Mock; + let info: Mock; + + beforeEach(() => { + log = mock.method(logger, 'log'); + error = mock.method(logger, 'error'); + warn = mock.method(logger, 'warn'); + info = 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); + }); +}); diff --git a/test/unit/RedisQueue/Cleanup.spec.ts b/test/unit/RedisQueue/Cleanup.spec.ts new file mode 100644 index 0000000..2901f6c --- /dev/null +++ b/test/unit/RedisQueue/Cleanup.spec.ts @@ -0,0 +1,35 @@ +/*! + * Additional RedisQueue tests: processCleanup catch branch + */ +import '../../mocks'; +import { describe, it, afterEach, mock, Mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { RedisQueue } from '../../../src'; +import { makeLogger } from '../../helpers'; + +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(); + }); +}); diff --git a/test/unit/RedisQueue/Connect.spec.ts b/test/unit/RedisQueue/Connect.spec.ts new file mode 100644 index 0000000..7eab7bd --- /dev/null +++ b/test/unit/RedisQueue/Connect.spec.ts @@ -0,0 +1,35 @@ +/*! + * Additional RedisQueue tests: connect() option fallbacks branches + */ +import '../../mocks'; +import { describe, it } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { RedisQueue, IMQMode } from '../../../src'; +import { makeLogger } from '../../helpers'; + +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(); + }); +}); diff --git a/test/unit/RedisQueue/ProcessCleanup.spec.ts b/test/unit/RedisQueue/ProcessCleanup.spec.ts new file mode 100644 index 0000000..bc3b739 --- /dev/null +++ b/test/unit/RedisQueue/ProcessCleanup.spec.ts @@ -0,0 +1,165 @@ +/*! + * Additional RedisQueue tests: processCleanup branches (connectedKeys filter, + * extra prefix, multi-scan/no-delete, null-match and falsy cleanupFilter) + */ +import '../../mocks'; +import { describe, it, afterEach, mock, Mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { RedisQueue, uuid } from '../../../src'; +import { RedisClientMock } from '../../mocks'; + +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(); + }); +}); diff --git a/test/RedisQueue.processDelayed.catch.spec.ts b/test/unit/RedisQueue/ProcessDelayed.spec.ts similarity index 52% rename from test/RedisQueue.processDelayed.catch.spec.ts rename to test/unit/RedisQueue/ProcessDelayed.spec.ts index 82fa5ca..8bfd5cd 100644 --- a/test/RedisQueue.processDelayed.catch.spec.ts +++ b/test/unit/RedisQueue/ProcessDelayed.spec.ts @@ -1,22 +1,16 @@ /*! * 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); +import '../../mocks'; +import { describe, it, afterEach, mock, Mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { RedisQueue } from '../../../src'; +import { makeLogger } from '../../helpers'; + +describe('RedisQueue.processDelayed extra branches', () => { + afterEach(() => { + mock.restoreAll(); + }); it('should emit error when evalsha throws', async () => { const logger = makeLogger(); @@ -27,7 +21,11 @@ describe('RedisQueue.processDelayed extra branches', function() { rq.scripts.moveDelayed.checksum = 'deadbeef'; const err = new Error('evalsha failed'); - const emitErrorStub = sinon.stub((RedisQueue as any).prototype, 'emitError'); + const emitErrorStub: Mock = mock.method( + (RedisQueue as any).prototype, + 'emitError', + () => undefined, + ); // Temporarily drop writer to force a synchronous error in processDelayed const originalWriter = rq.writer; @@ -35,12 +33,14 @@ describe('RedisQueue.processDelayed extra branches', function() { await rq['processDelayed'](rq.key); - expect(emitErrorStub.called).to.be.true; - expect(emitErrorStub.firstCall.args[0]).to.equal('OnProcessDelayed'); + assert.ok(emitErrorStub.mock.callCount() > 0); + assert.equal( + emitErrorStub.mock.calls[0].arguments[0], + 'OnProcessDelayed', + ); // Restore writer and cleanup rq['writer'] = originalWriter; - emitErrorStub.restore(); await rq.destroy(); }); }); diff --git a/test/unit/RedisQueue/Publish.spec.ts b/test/unit/RedisQueue/Publish.spec.ts new file mode 100644 index 0000000..6161654 --- /dev/null +++ b/test/unit/RedisQueue/Publish.spec.ts @@ -0,0 +1,72 @@ +/*! + * Additional RedisQueue tests: publish() branches + */ +import '../../mocks'; +import { describe, it, mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { RedisQueue, IMQMode } from '../../../src'; +import { makeLogger } from '../../helpers'; + +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); + }); +}); diff --git a/test/RedisQueue.ts b/test/unit/RedisQueue/RedisQueue.spec.ts similarity index 61% rename from test/RedisQueue.ts rename to test/unit/RedisQueue/RedisQueue.spec.ts index 71c6c98..dfd34b3 100644 --- a/test/RedisQueue.ts +++ b/test/unit/RedisQueue/RedisQueue.spec.ts @@ -21,36 +21,41 @@ * 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 { logger } from '../../mocks'; +import { describe, it, beforeEach } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { RedisQueue, uuid, IMQMode } from '../../../src'; import Redis from 'ioredis'; process.setMaxListeners(100); -describe('RedisQueue', function() { - this.timeout(30000); - +describe('RedisQueue', () => { it('should be a class', () => { - expect(typeof RedisQueue).to.equal('function'); + assert.equal(typeof RedisQueue, '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'); + 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[] = []; - 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); + 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())); }); @@ -59,8 +64,11 @@ describe('RedisQueue', function() { 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) } + try { + await rq.start(); + } catch (err) { + assert.ok(err instanceof TypeError); + } rq.destroy().catch(); }); @@ -68,17 +76,17 @@ describe('RedisQueue', function() { try { const rq: any = new RedisQueue(uuid(), { logger }); await rq.start(); - expect(rq.reader).to.be.instanceof(Redis); + assert.ok(rq.reader instanceof Redis); await rq.destroy(); + } catch (err) { + console.error(err); } - - 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); + assert.ok(rq.writer instanceof Redis); await rq.destroy(); }); @@ -87,8 +95,8 @@ describe('RedisQueue', function() { 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); + assert.equal(await rq1.watcherCount(), 1); + assert.equal(await rq2.watcherCount(), 1); await rq1.destroy(); await rq2.destroy(); }); @@ -98,7 +106,7 @@ describe('RedisQueue', function() { await rq.start(); await rq.stop(); await rq.start(); - expect(rq.reader).to.be.instanceof(Redis); + assert.ok(rq.reader instanceof Redis); await rq.destroy(); }); @@ -108,8 +116,10 @@ describe('RedisQueue', function() { try { await rq.start(); await rq.start(); - } catch (err) { passed = false } - expect(passed).to.be.true; + } catch (err) { + passed = false; + } + assert.equal(passed, true); await rq.destroy(); }); }); @@ -117,46 +127,49 @@ describe('RedisQueue', function() { describe('stop()', () => { it('should stop reading messages from queue', async () => { const name = uuid(); - const rq: any = new RedisQueue(name, { logger }); + const rq: any = new RedisQueue(name, { logger }); await rq.start(); - expect(rq.reader).to.be.instanceof(Redis); + assert.ok(rq.reader instanceof Redis); await rq.stop(); - expect(rq.reader).not.to.be.ok; + assert.ok(!rq.reader); await rq.destroy(); }); }); describe('send()', () => { - it('should send given message to a given queue', (done) => { + 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) => { - expect(msg).to.deep.equal(message); - expect(id).not.to.be.undefined; - expect(from).to.equal('IMQUnitTestsFrom'); + 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(); - });}); + rqFrom.start().then(() => { + rqTo.start().then(() => { + rqFrom.send('IMQUnitTestsTo', message).catch(); + }); + }); }); - it('should guaranty message delivery if safeDelivery is on', (done) => { + 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, + logger, + safeDelivery: true, }); - rq.on('message', (msg) => { - expect(msg).to.deep.equal(message); + rq.on('message', msg => { + assert.deepEqual(msg, message); rq.destroy().catch(); done(); }); @@ -164,7 +177,7 @@ describe('RedisQueue', function() { rq.start().then(async () => rq.send('IMQSafe', message)); }); - it('should deliver message with the given delay', (done) => { + 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 }); @@ -173,22 +186,24 @@ describe('RedisQueue', function() { 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'); + 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(); - });}); + 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) => { + it('should trigger an error in case of redis error', (t, done) => { const lrange = Redis.prototype.lrange; let wasDone = false; Redis.prototype.lrange = async (): Promise => @@ -196,13 +211,17 @@ describe('RedisQueue', function() { const message: any = { hello: 'safe delivery' }; const rq = new RedisQueue('IMQSafe', { - logger, safeDelivery: true + logger, + safeDelivery: true, }); - process.on('unhandledRejection', function(e) { - expect((e as any).message).to.be.equal('Wrong messages count'); + rq.on('error', function (e) { + assert.equal((e as any).message, 'Wrong messages count'); Redis.prototype.lrange = lrange; - !wasDone && done(); + if (!wasDone) { + rq.destroy().catch(); + done(); + } wasDone = true; }); @@ -214,32 +233,30 @@ describe('RedisQueue', function() { let rq: any; beforeEach(async () => { - rq = new RedisQueue(uuid(), { logger }); + 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; + assert.ok(!rq.watcher); + assert.ok(!rq.reader); + assert.ok(!rq.writer); }); it('should remove all event listeners', async () => { await rq.destroy(); - expect(rq.listenerCount()).to.equal(0); + assert.equal(rq.listenerCount(), 0); }); }); describe('clear()', () => { it('should clean-up queue data in redis', async () => { - const rq: any = new RedisQueue(uuid(), { logger }); + 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; + assert.ok(!(await rq.writer.exists(rq.key))); + assert.ok(!(await rq.writer.exists(`${rq.key}:delayed`))); rq.destroy().catch(); }); }); @@ -249,13 +266,13 @@ describe('RedisQueue', function() { const rq: any = new RedisQueue(uuid(), { logger, cleanup: true, - cleanupFilter: 'test*' + cleanupFilter: 'test*', }); await rq.start(); // Call processCleanup directly const result = await rq.processCleanup(); - expect(result).to.equal(rq); + assert.equal(result, rq); await rq.destroy(); }); @@ -263,12 +280,12 @@ describe('RedisQueue', function() { it('should return early when cleanup option is disabled', async () => { const rq: any = new RedisQueue(uuid(), { logger, - cleanup: false + cleanup: false, }); await rq.start(); const result = await rq.processCleanup(); - expect(result).to.be.undefined; + assert.equal(result, undefined); await rq.destroy(); }); @@ -280,13 +297,13 @@ describe('RedisQueue', function() { // Don't start, so writer will be null const lockResult = await rq.lock(); - expect(lockResult).to.be.false; + assert.equal(lockResult, false); const unlockResult = await rq.unlock(); - expect(unlockResult).to.be.false; + assert.equal(unlockResult, false); const isLockedResult = await rq.isLocked(); - expect(isLockedResult).to.be.false; + assert.equal(isLockedResult, false); await rq.destroy(); }); @@ -297,15 +314,15 @@ describe('RedisQueue', function() { // Test locking const lockResult = await rq.lock(); - expect(lockResult).to.be.a('boolean'); + assert.equal(typeof lockResult, 'boolean'); // Test checking if locked const isLockedResult = await rq.isLocked(); - expect(isLockedResult).to.be.a('boolean'); + assert.equal(typeof isLockedResult, 'boolean'); // Test unlocking const unlockResult = await rq.unlock(); - expect(unlockResult).to.be.a('boolean'); + assert.equal(typeof unlockResult, 'boolean'); await rq.destroy(); }); @@ -313,14 +330,22 @@ describe('RedisQueue', function() { 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; + 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(); @@ -330,11 +355,11 @@ describe('RedisQueue', function() { const name = uuid(); const rq: any = new RedisQueue(name, { logger }); - expect(rq.key).to.be.a('string'); - expect(rq.key).to.include(name); + assert.equal(typeof rq.key, 'string'); + assert.ok(String(rq.key).includes(name)); - expect(rq.lockKey).to.be.a('string'); - expect(rq.lockKey).to.include('watch:lock'); + assert.equal(typeof rq.lockKey, 'string'); + assert.ok(String(rq.lockKey).includes('watch:lock')); await rq.destroy(); }); diff --git a/test/unit/RedisQueue/Send.spec.ts b/test/unit/RedisQueue/Send.spec.ts new file mode 100644 index 0000000..de59a30 --- /dev/null +++ b/test/unit/RedisQueue/Send.spec.ts @@ -0,0 +1,57 @@ +/*! + * Additional RedisQueue tests: send() extra branches and worker-only mode + */ +import '../../mocks'; +import { describe, it, mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { RedisQueue, IMQMode } from '../../../src'; +import { makeLogger } from '../../helpers'; + +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 + const startStub = 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); + }); +}); diff --git a/test/unit/RedisQueue/Unsubscribe.spec.ts b/test/unit/RedisQueue/Unsubscribe.spec.ts new file mode 100644 index 0000000..a7165f2 --- /dev/null +++ b/test/unit/RedisQueue/Unsubscribe.spec.ts @@ -0,0 +1,40 @@ +/*! + * Additional RedisQueue tests: unsubscribe() cleanup path + */ +import '../../mocks'; +import { describe, it, mock, Mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { RedisQueue } from '../../../src'; +import { makeLogger } from '../../helpers'; + +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); + }); +}); diff --git a/test/UDPClusterManager.destroySocket.spec.ts b/test/unit/UDPClusterManager/DestroySocket.spec.ts similarity index 72% rename from test/UDPClusterManager.destroySocket.spec.ts rename to test/unit/UDPClusterManager/DestroySocket.spec.ts index 70fff96..e70a4d6 100644 --- a/test/UDPClusterManager.destroySocket.spec.ts +++ b/test/unit/UDPClusterManager/DestroySocket.spec.ts @@ -1,9 +1,10 @@ /*! * UDPClusterManager.destroyWorker() behavior tests aligned with implementation */ -import './mocks'; -import { expect } from 'chai'; -import { UDPClusterManager } from '../src'; +import '../../mocks'; +import { describe, it } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { UDPClusterManager } from '../../../src'; describe('UDPClusterManager.destroyWorker()', () => { it('should resolve when worker is undefined (no-op)', async () => { @@ -13,7 +14,10 @@ describe('UDPClusterManager.destroyWorker()', () => { 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 workers = (UDPClusterManager as any).workers as Record< + string, + any + >; const key = '1.2.3.4:65000'; let terminated = false; @@ -24,13 +28,15 @@ describe('UDPClusterManager.destroyWorker()', () => { setImmediate(() => cb({ type: 'stopped' })); } }, - terminate: () => { terminated = true; }, + terminate: () => { + terminated = true; + }, }; workers[key] = fakeWorker; await destroy(key, fakeWorker); - expect(terminated).to.equal(true); - expect(workers[key]).to.be.undefined; + assert.equal(terminated, true); + assert.equal(workers[key], undefined); }); }); diff --git a/test/UDPClusterManager.ts b/test/unit/UDPClusterManager/UDPClusterManager.spec.ts similarity index 68% rename from test/UDPClusterManager.ts rename to test/unit/UDPClusterManager/UDPClusterManager.spec.ts index 98268a1..600c1b1 100644 --- a/test/UDPClusterManager.ts +++ b/test/unit/UDPClusterManager/UDPClusterManager.spec.ts @@ -20,11 +20,13 @@ * 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). */ -import './mocks'; -import { expect } from 'chai'; -import { UDPClusterManager } from '../src'; -import * as sinon from 'sinon'; +import '../../mocks'; +import { describe, it, afterEach, mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { UDPClusterManager } from '../../../src'; const testMessageUp = { name: 'IMQUnitTest', @@ -56,10 +58,13 @@ const emitMessage = ( }); }; -describe('UDPBroadcastClusterManager', function() { - this.timeout(5000); +describe('UDPBroadcastClusterManager', () => { + afterEach(() => { + mock.restoreAll(); + }); + it('should be a class', () => { - expect(typeof UDPClusterManager).to.equal('function'); + assert.equal(typeof UDPClusterManager, 'function'); }); it('should call add on cluster', async () => { @@ -70,12 +75,12 @@ describe('UDPBroadcastClusterManager', function() { }; const manager: any = new UDPClusterManager(); - sinon.spy(cluster, 'add'); + const add = mock.method(cluster, 'add'); manager.init(cluster); emitMessage(manager, 'cluster:add'); - expect(cluster.add.called).to.be.true; + assert.equal(add.mock.callCount() > 0, true); await manager.destroy(); }); @@ -89,12 +94,12 @@ describe('UDPBroadcastClusterManager', function() { }; const manager: any = new UDPClusterManager(); - sinon.spy(cluster, 'add'); + const add = mock.method(cluster, 'add'); manager.init(cluster); emitMessage(manager, 'cluster:add'); - expect(cluster.add.called).to.be.false; + assert.equal(add.mock.callCount() > 0, false); await manager.destroy(); }); @@ -108,21 +113,21 @@ describe('UDPBroadcastClusterManager', function() { }; const manager: any = new UDPClusterManager(); - sinon.spy(cluster, 'remove'); + const remove = mock.method(cluster, 'remove'); manager.init(cluster); emitMessage(manager, 'cluster:remove'); - expect(cluster.remove.called).to.be.true; + assert.equal(remove.mock.callCount() > 0, true); await manager.destroy(); }); - it('should handle server timeout and removal', (done) => { + it('should handle server timeout and removal', (t, done) => { let addedServer: any = null; const cluster: any = { add: () => {}, remove: async (server: any) => { - expect(server).to.equal(addedServer); + assert.equal(server, addedServer); await manager.destroy(); }, find: (message: any) => { @@ -190,7 +195,42 @@ describe('UDPBroadcastClusterManager', function() { // Should not throw when no sockets exist await manager.destroy(); - expect(Object.keys((UDPClusterManager as any).sockets)).to.have.length(0); + assert.equal( + Object.keys((UDPClusterManager as any).sockets).length, + 0, + ); }); }); }); + +describe('UDPClusterManager - cover remaining branches', () => { + it('destroySocket should call socket.unref() when socket is present', async () => { + // Prepare fake socket with unref + const unref = mock.fn(); + const removeAll = mock.fn(); + 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); + assert.equal(unref.mock.callCount() > 0, true); + assert.equal((UDPClusterManager as any).sockets[key], undefined); + }); + + it('destroySocket should work when socket.unref() is absent (optional chaining negative branch)', async () => { + const removeAll = mock.fn(); + 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 + assert.equal((UDPClusterManager as any).sockets[key], undefined); + }); +}); diff --git a/test/uuid.ts b/test/unit/Uuid.spec.ts similarity index 73% rename from test/uuid.ts rename to test/unit/Uuid.spec.ts index 7543f65..4d87310 100644 --- a/test/uuid.ts +++ b/test/unit/Uuid.spec.ts @@ -21,33 +21,32 @@ * 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 +import '../mocks'; +import { describe, it } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { uuid } from '../..'; +describe('uuid()', () => { 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}' + - '$', + '[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; + assert.equal(regex.test(uuid()), true); } }); @@ -58,7 +57,7 @@ describe('uuid()', function() { keyStore[uuid()] = 1; } - expect(Object.keys(keyStore).length).to.be.equal(steps); + assert.equal(Object.keys(keyStore).length, steps); }); it('should generate 100,000 identifiers in less than a second', () => { @@ -68,6 +67,6 @@ describe('uuid()', function() { uuid(); } - expect(Date.now() - start).to.be.below(1000); + assert.ok(Date.now() - start < 1000); }); }); diff --git a/tsconfig.json b/tsconfig.json index 7b13412..b81be21 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,22 @@ { "compilerOptions": { - "module": "commonjs", + "module": "node16", + "moduleResolution": "node16", + "target": "es2022", + "lib": [ + "es2023" + ], "declaration": true, - "noImplicitAny": true, - "strictNullChecks": true, + "sourceMap": true, + "inlineSources": true, "removeComments": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "strict": true, "noUnusedLocals": false, "noUnusedParameters": false, - "moduleResolution": "node", - "sourceMap": true, - "inlineSources": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "target": "es2017", - "lib": [ - "dom", - "es2017" - ] + "experimentalDecorators": false, + "emitDecoratorMetadata": false } } From 2f7ed2164f4eb80522ea52bb5f892f3022fb2675 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Mon, 6 Jul 2026 19:30:32 +0200 Subject: [PATCH 02/20] fix:: added files --- .oxfmtrc.json | 9 +++++++++ .oxlintrc.json | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 .oxfmtrc.json create mode 100644 .oxlintrc.json 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/" + ] +} From 727410e85be8f6e587949491d332df608b61c23d Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Mon, 6 Jul 2026 21:40:36 +0200 Subject: [PATCH 03/20] feat: refactored RedisQueue & fixed inspected issues --- .codebeatignore | 1 - .codebeatsettings | 7 - README.md | 3 +- benchmark/index.ts | 3 +- benchmark/redis-test.ts | 10 +- src/ClusterManager.ts | 4 +- src/ClusteredRedisQueue.ts | 35 +- src/IMessageQueue.ts | 47 +- src/RedisQueue.ts | 1134 ++++++++++------- src/UDPWorker.ts | 4 +- src/{ => helpers}/copyEventEmitter.ts | 4 +- src/index.ts | 21 +- src/profile.ts | 10 +- src/redis.ts | 1 - test/helpers/index.ts | 16 +- src/uuid.ts => test/helpers/makeLogger.ts | 23 +- test/mocks/redis.ts | 76 +- .../ClusteredRedisQueue/AddServer.spec.ts | 71 -- .../ClusteredRedisQueue.spec.ts | 398 ------ .../ClusteredRedisQueue/Initialize.spec.ts | 46 - .../ClusteredRedisQueue/MatchServers.spec.ts | 44 - test/unit/CopyEventEmitter.spec.ts | 246 ---- test/unit/IMQ.spec.ts | 71 -- test/unit/IMessageQueue.spec.ts | 2 - test/unit/Profile/Decorator.spec.ts | 72 -- test/unit/Profile/Profile.spec.ts | 297 ----- test/unit/RedisQueue/Cleanup.spec.ts | 35 - test/unit/RedisQueue/Connect.spec.ts | 35 - test/unit/RedisQueue/ProcessCleanup.spec.ts | 165 --- test/unit/RedisQueue/ProcessDelayed.spec.ts | 46 - test/unit/RedisQueue/Publish.spec.ts | 72 -- test/unit/RedisQueue/RedisQueue.spec.ts | 367 ------ test/unit/RedisQueue/Send.spec.ts | 57 - test/unit/RedisQueue/Unsubscribe.spec.ts | 40 - .../UDPClusterManager/DestroySocket.spec.ts | 42 - .../UDPClusterManager.spec.ts | 236 ---- test/unit/Uuid.spec.ts | 72 -- tsconfig.json | 28 +- 38 files changed, 849 insertions(+), 2992 deletions(-) delete mode 100644 .codebeatignore delete mode 100644 .codebeatsettings rename src/{ => helpers}/copyEventEmitter.ts (94%) rename src/uuid.ts => test/helpers/makeLogger.ts (66%) delete mode 100644 test/unit/ClusteredRedisQueue/AddServer.spec.ts delete mode 100644 test/unit/ClusteredRedisQueue/ClusteredRedisQueue.spec.ts delete mode 100644 test/unit/ClusteredRedisQueue/Initialize.spec.ts delete mode 100644 test/unit/ClusteredRedisQueue/MatchServers.spec.ts delete mode 100644 test/unit/CopyEventEmitter.spec.ts delete mode 100644 test/unit/IMQ.spec.ts delete mode 100644 test/unit/Profile/Decorator.spec.ts delete mode 100644 test/unit/Profile/Profile.spec.ts delete mode 100644 test/unit/RedisQueue/Cleanup.spec.ts delete mode 100644 test/unit/RedisQueue/Connect.spec.ts delete mode 100644 test/unit/RedisQueue/ProcessCleanup.spec.ts delete mode 100644 test/unit/RedisQueue/ProcessDelayed.spec.ts delete mode 100644 test/unit/RedisQueue/Publish.spec.ts delete mode 100644 test/unit/RedisQueue/RedisQueue.spec.ts delete mode 100644 test/unit/RedisQueue/Send.spec.ts delete mode 100644 test/unit/RedisQueue/Unsubscribe.spec.ts delete mode 100644 test/unit/UDPClusterManager/DestroySocket.spec.ts delete mode 100644 test/unit/UDPClusterManager/UDPClusterManager.spec.ts delete mode 100644 test/unit/Uuid.spec.ts 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/README.md b/README.md index b311c25..9cfc30b 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ With current implementation on RedisQueue: # Requirements Currently this module have only one available adapter which is Redis server -related. So redis-server > 3.8+ is required. +related. 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: diff --git a/benchmark/index.ts b/benchmark/index.ts index f5f0cb5..0ca830a 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -27,7 +27,8 @@ import * as fs from 'fs'; import { parseArgs } from 'node:util'; import { run } from './redis-test'; import { resolve } from 'path'; -import { uuid, AnyJson } from '..'; +import { randomUUID as uuid } from 'crypto'; +import { AnyJson } from '..'; import { setAffinity } from './affinity'; const cluster: any = require('cluster'); diff --git a/benchmark/redis-test.ts b/benchmark/redis-test.ts index 3877fc0..3811b5f 100644 --- a/benchmark/redis-test.ts +++ b/benchmark/redis-test.ts @@ -21,14 +21,8 @@ * 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 'crypto'; +import IMQ, { IMQOptions, IJson, pack, JsonObject, AnyJson } from '../index'; /** * Sample message used within tests diff --git a/src/ClusterManager.ts b/src/ClusterManager.ts index 00de6de..ba07bcb 100644 --- a/src/ClusterManager.ts +++ b/src/ClusterManager.ts @@ -22,7 +22,7 @@ * to get commercial licensing options. */ import { IMessageQueueConnection, IServerInput } from './IMessageQueue'; -import { uuid } from './uuid'; +import { randomUUID } from 'crypto'; export interface ICluster { add: (server: IServerInput) => IMessageQueueConnection; @@ -41,7 +41,7 @@ export abstract class ClusterManager { public init(cluster: ICluster): InitializedCluster { const initializedCluster = Object.assign(cluster, { - id: uuid(), + id: randomUUID(), }) as InitializedCluster; this.clusters.push(initializedCluster); diff --git a/src/ClusteredRedisQueue.ts b/src/ClusteredRedisQueue.ts index 6240a2f..8c7b775 100644 --- a/src/ClusteredRedisQueue.ts +++ b/src/ClusteredRedisQueue.ts @@ -92,7 +92,6 @@ export class ClusteredRedisQueue * * @type {IMessageQueueConnection[]} */ - // tslint:disable-next-line:completed-docs private servers: ClusterServer[] = []; /** @@ -102,7 +101,6 @@ export class ClusteredRedisQueue */ private currentQueue: number = 0; - // noinspection TypeScriptFieldCanBeMadeReadonly /** * Total length of RedisQueue instances * @@ -150,7 +148,6 @@ export class ClusteredRedisQueue 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) { @@ -280,7 +277,6 @@ export class ClusteredRedisQueue } } - // noinspection JSUnusedGlobalSymbols /** * Clears queue data in queue host application. * Supposed to be an async function. @@ -322,13 +318,18 @@ export class ClusteredRedisQueue * @param {string} message * @return {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); @@ -337,7 +338,6 @@ export class ClusteredRedisQueue } // EventEmitter interface - // istanbul ignore next /** * Applies the named EventEmitter method to every underlying emitter, * forwarding the call across the whole cluster. Dispatch is reflective @@ -363,102 +363,86 @@ export class ClusteredRedisQueue return results; } - // istanbul ignore next public on(...args: any[]): this { this.applyToEmitters('on', args); return this; } - // istanbul ignore next - // noinspection JSUnusedGlobalSymbols public off(...args: any[]): this { this.applyToEmitters('off', args); return this; } - // istanbul ignore next public once(...args: any[]): this { this.applyToEmitters('once', args); return this; } - // istanbul ignore next public addListener(...args: any[]): this { this.applyToEmitters('addListener', args); return this; } - // istanbul ignore next public removeListener(...args: any[]): this { this.applyToEmitters('removeListener', args); return this; } - // istanbul ignore next public removeAllListeners(...args: any[]): this { this.applyToEmitters('removeAllListeners', args); return this; } - // istanbul ignore next public prependListener(...args: any[]): this { this.applyToEmitters('prependListener', args); return this; } - // istanbul ignore next public prependOnceListener(...args: any[]): this { this.applyToEmitters('prependOnceListener', args); return this; } - // istanbul ignore next public setMaxListeners(...args: any[]): this { this.applyToEmitters('setMaxListeners', args); return this; } - // istanbul ignore next // 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(); } - // istanbul ignore next public rawListeners(...args: any[]): any[] { return this.applyToEmitters('rawListeners', args).flat(); } - // istanbul ignore next public getMaxListeners(): number { return this.templateEmitter.getMaxListeners(); } - // istanbul ignore next public emit(...args: any[]): boolean { this.applyToEmitters('emit', args); return true; } - // istanbul ignore next public eventNames(): (keyof EventMap)[] { const source = this.imqs[0] || this.templateEmitter; return source.eventNames() as (keyof EventMap)[]; } - // istanbul ignore next public listenerCount(...args: any[]): number { const source = this.imqs[0] || this.templateEmitter; const fn = source.listenerCount as (...a: any[]) => number; @@ -466,7 +450,6 @@ export class ClusteredRedisQueue return fn.apply(source, args); } - // istanbul ignore next public async publish(data: JsonObject, toName?: string): Promise { const promises: Array> = []; @@ -477,7 +460,6 @@ export class ClusteredRedisQueue await Promise.all(promises); } - // istanbul ignore next public async subscribe( channel: string, handler: (data: JsonObject) => any, @@ -493,7 +475,6 @@ export class ClusteredRedisQueue await Promise.all(promises); } - // istanbul ignore next public async unsubscribe(): Promise { this.state.subscription = null; diff --git a/src/IMessageQueue.ts b/src/IMessageQueue.ts index 032136f..a3e72bb 100644 --- a/src/IMessageQueue.ts +++ b/src/IMessageQueue.ts @@ -282,6 +282,28 @@ export interface IMQOptions extends Partial { */ clusterManagers?: ClusterManager[]; + /** + * Enables/disables process signal handling (SIGTERM, SIGINT, SIGABRT) + * by the queue. When enabled, the queue frees its watcher lock and + * exits the process on those signals. Disable when the host + * application manages its own shutdown sequence. + * + * @default true + * @type {boolean} + */ + handleSignals?: boolean; + + /** + * When enabled, send() resolves only after the message write is + * confirmed by redis (and rejects on write failures). By default + * writes are fire-and-forget for maximum throughput and failures are + * reported through the optional errorHandler argument only. + * + * @default false + * @type {boolean} + */ + awaitWrites?: boolean; + /** * Enables/disables verbose logging * @@ -317,7 +339,8 @@ export type IMessageQueueConstructor = new ( * * @example * ~~~typescript - * import { IMessageQueue, EventEmitter, uuid } from '@imqueue/core'; + * import { IMessageQueue, EventEmitter } from '@imqueue/core'; + * import { randomUUID } from 'crypto'; * * class SomeMQAdapter implements IMessageQueue extends EventEmitter { * public async start(): Promise { @@ -333,7 +356,7 @@ export type IMessageQueueConstructor = new ( * message: JsonObject, * delay?: number * ): Promise { - * const messageId = uuid(); + * const messageId = randomUUID(); * // ... implementation goes here * return messageId; * } @@ -374,7 +397,7 @@ export interface IMessageQueue extends EventEmitter { start(): Promise; /** - * Stops the queue (should stop handle queue messages). + * Stops the queue (should stop to handle queue messages). * Supposed to be an async function. * * @returns {Promise} @@ -382,13 +405,13 @@ export interface IMessageQueue extends EventEmitter { stop(): Promise; /** - * Sends a message to given queue name with the given data. + * Sends a message to the 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, message will be handled in the - * target queue after specified period of time in milliseconds. + * @param {number} [delay] - if specified, a message will be handled in the + * 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. * @returns {Promise} - message identifier @@ -401,7 +424,7 @@ export interface IMessageQueue extends EventEmitter { ): 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 @@ -420,11 +443,11 @@ export interface IMessageQueue extends EventEmitter { 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 + * If toName specified will publish to pubsub with a 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 + * on other pubsub channels. Different names should be in the same namespace * (same imq prefix) * * @param {JsonObject} data - data to publish as channel message @@ -434,7 +457,7 @@ export interface IMessageQueue extends EventEmitter { publish(data: JsonObject, toName?: string): Promise; /** - * Safely destroys current queue, unregistered all set event + * Safely destroys the current queue, unregistered all set event * listeners and connections. * Supposed to be an async function. * diff --git a/src/RedisQueue.ts b/src/RedisQueue.ts index a3da54d..2b2f368 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 { randomUUID } from 'crypto'; +import { hostname } from 'os'; import { IMessageQueue, IRedisClient, @@ -34,94 +33,84 @@ 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).*$/; -export const DEFAULT_IMQ_OPTIONS: IMQOptions = { - host: 'localhost', - port: 6379, - cleanup: false, - cleanupFilter: '*', - logger: console, - prefix: 'imq', - safeDelivery: false, - safeDeliveryTtl: 5000, - useGzip: false, - watcherCheckDelay: 5000, -}; - -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'); +/** Base delay (ms) for the exponential reconnection backoff */ +const RECONNECT_BASE_DELAY = 1000; - sha.update(str); +/** Upper cap (ms) for the exponential reconnection backoff */ +const RECONNECT_MAX_DELAY = 30000; - return sha.digest('hex'); -} +/** SCAN batch size used while sweeping keys */ +const SCAN_COUNT = '1000'; /** - * Returns random integer between given min and max + * ioredis retry strategy that disables the built-in reconnection — this + * queue performs its own capped-backoff reconnection instead. * - * @param {number} min - * @param {number} max - * @returns {number} + * @return {null} */ -export function intrand(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; +function noRetryStrategy(): null { + return null; } /** - * Compress given data and returns binary string + * Resolves after the given number of milliseconds. * - * @param {any} data - * @returns {string} + * @param {number} ms + * @return {Promise} */ -// istanbul ignore next -export function pack(data: any): string { - return gzip(JSON.stringify(data)).toString('binary'); +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } -/** - * 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 DEFAULT_IMQ_OPTIONS: IMQOptions = { + host: 'localhost', + port: 6379, + cleanup: false, + cleanupFilter: '*', + logger: console, + prefix: 'imq', + safeDelivery: false, + safeDeliveryTtl: 5000, + useGzip: false, + watcherCheckDelay: 5000, + handleSignals: true, + awaitWrites: false, +}; + +export const IMQ_SHUTDOWN_TIMEOUT = envInt('IMQ_SHUTDOWN_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; - /** * Writer connections collection * @@ -136,6 +125,27 @@ export class RedisQueue */ 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) */ @@ -191,21 +201,46 @@ export class RedisQueue 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) => any>} */ - private signalsInitialized: boolean = false; + private subscriptionHandlers: Array<(data: JsonObject) => any> = []; /** * 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 reconnectTimers: Partial< + Record + > = {}; private reconnectAttempts: Partial> = {}; private reconnecting: Partial> = {}; @@ -216,11 +251,10 @@ export class RedisQueue 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 } } = { moveDelayed: { code: @@ -272,12 +306,14 @@ export class RedisQueue 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}`; + 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 @@ -309,14 +345,12 @@ export class RedisQueue channel: string, handler: (data: JsonObject) => any, ): Promise { - // istanbul ignore next if (!channel) { throw new TypeError( `${channel}: No subscription channel name provided!`, ); } - // istanbul ignore next if (this.subscriptionName && this.subscriptionName !== channel) { throw new TypeError( `Invalid channel name provided: expected "${ @@ -331,11 +365,29 @@ export class RedisQueue 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 + * + * @access private + * @param {IRedisClient} chan + * @param {(data: JsonObject) => any} handler + */ + private attachSubscriptionHandler( + chan: IRedisClient, + handler: (data: JsonObject) => any, + ): 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( @@ -344,8 +396,36 @@ export class RedisQueue )}`, ); }); + } - this.verbose(`Subscribed to ${channel} channel`); + /** + * Restores subscription state on a freshly created subscription + * connection. Used after a connection replacement on reconnect, + * otherwise the new connection would silently stay unsubscribed. + * + * @access private + * @return {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(`Restored subscription to ${this.subscriptionName}`); } /** @@ -369,7 +449,9 @@ export class RedisQueue } 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}`); @@ -378,6 +460,7 @@ export class RedisQueue this.subscriptionName = undefined; this.subscription = undefined; + this.subscriptionHandlers = []; } /** @@ -423,7 +506,6 @@ export class RedisQueue const connPromises = []; - // istanbul ignore next if (!this.reader && this.isWorker()) { this.verbose('Initializing reader...'); connPromises.push(this.connect('reader', this.options)); @@ -438,44 +520,137 @@ export class RedisQueue this.verbose('Connections initialized'); - if (!this.signalsInitialized) { - this.verbose('Setting up OS signal handlers...'); - // istanbul ignore next - const free = async () => { - let exitCode = 0; - - 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; - } - }; + RedisQueue.instances.add(this); - 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. + * + * @access private + */ + 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. + * + * @access private + * @return {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 delayed messages when + * keyspace notifications are unavailable. + * + * @access private + */ + 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 delayed messages as a keyspace-notification + * fallback. Errors are contained so the interval never crashes. + * + * @access private + * @return {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 + * + * @access private + */ + private stopWatcherCheck(): void { + if (this.watcherCheckInterval) { + clearInterval(this.watcherCheckInterval); + this.watcherCheckInterval = undefined; + } + } + /** * Sends a given message to a given queue (by name) * @@ -495,7 +670,6 @@ export class RedisQueue throw new TypeError('IMQ: Unable to publish in WORKER only mode!'); } - // istanbul ignore next if (!this.writer) { await this.start(); } @@ -504,30 +678,61 @@ export class RedisQueue 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}`); if (errorHandler) { - errorHandler(error as unknown as Error); + errorHandler( + error instanceof Error + ? error + : new Error(String(error)), + ); } } }; + if (this.options.awaitWrites) { + // confirmed writes mode: resolve after redis accepted the + // message, reject on 'write' failures + try { + if (delay) { + await this.writer.zadd( + `${key}:delayed`, + Date.now() + delay, + packet, + ); + await this.writer.set( + `${key}:${id}:ttl`, + '', + 'PX', + delay, + 'NX', + ); + } else { + await this.writer.lpush(key, packet); + } + } catch (err) { + onWriteError(err, delay ? 'ZADD/SET' : 'LPUSH'); + + throw err; + } + + return id; + } + if (delay) { this.writer.zadd( `${key}:delayed`, Date.now() + delay, packet, (err: any) => { - // istanbul ignore next if (err) { - cb(err, 'ZADD'); + onWriteError(err, 'ZADD'); return; } @@ -540,26 +745,30 @@ export class RedisQueue delay, 'NX', (err: any) => { - // istanbul ignore next if (err) { - cb(err, 'SET'); + onWriteError(err, 'SET'); return; } }, ) - .catch((err: any) => cb(err, 'SET')); + .catch((err: any) => onWriteError(err, 'SET')); }, ); } else { - this.writer.lpush(key, packet, (err: any) => { - // istanbul ignore next + const result: any = this.writer.lpush(key, packet, (err: any) => { if (err) { - cb(err, 'LPUSH'); + 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: any) => onWriteError(err, 'LPUSH')); + } } return id; @@ -576,7 +785,7 @@ export class RedisQueue if (this.reader) { this.verbose('Destroying reader...'); - this.destroyChannel('reader', this); + this.destroyChannel('reader'); delete this.reader; } @@ -589,19 +798,37 @@ export class RedisQueue } /** - * 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!'); @@ -628,7 +855,6 @@ export class RedisQueue this.verbose('Expired queue keys cleared!'); } catch (err) { - // istanbul ignore next if (this.initialized) { this.logger.error( `${this.name}: error clearing the redis queue host ${ @@ -674,7 +900,6 @@ export class RedisQueue return this.mode === IMQMode.BOTH || this.mode === IMQMode.WORKER; } - // noinspection JSMethodCanBeStatic /** * Writer connection associated with this queue instance * @@ -684,13 +909,11 @@ export class RedisQueue 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; } @@ -704,24 +927,70 @@ export class RedisQueue 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. + * + * @access private + * @param {RedisConnectionChannel} channel + * @return {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. + * + * @access private + * @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; } @@ -754,7 +1023,7 @@ export class RedisQueue 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!'); } @@ -766,10 +1035,26 @@ export class RedisQueue * @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!'); } @@ -781,26 +1066,23 @@ export class RedisQueue * @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(() => { - client.disconnect(false); - }) - .catch(e => { - this.verbose(`Error quitting ${channel}: ${e}`); - }); - } catch (error) { - this.verbose(`Error destroying ${channel}: ${error}`); - } + if (!client) { + return; + } + + try { + client.removeAllListeners(); + client + .quit() + .then(() => client.disconnect(false)) + .catch((error: unknown) => + this.verbose(`Error quitting ${channel}: ${error}`), + ); + } catch (error) { + this.verbose(`Error destroying ${channel}: ${error}`); } } @@ -810,36 +1092,31 @@ export class RedisQueue * @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...`); - // istanbul ignore next - if (context[channel]) { - return context[channel]; + const existing = this.connectionOf(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, @@ -849,8 +1126,8 @@ export class RedisQueue lazyConnect: true, }); - context[channel] = redis; - context[channel].__imq = true; + this.bindConnection(channel, redis); + redis.__imq = true; for (const event of [ 'wait', @@ -859,20 +1136,18 @@ export class RedisQueue '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, + this.name, channel, this.redisKey, process.pid, @@ -888,22 +1163,12 @@ export class RedisQueue 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; } /** @@ -914,76 +1179,87 @@ export class RedisQueue * @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})`, + `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; - } - - try { - switch (channel) { - case 'watcher': - this.destroyWatcher(); - break; - case 'writer': - this.destroyWriter(); - break; - case 'reader': - this.destroyChannel(channel, this); - this.reader = undefined; + this.reconnectTimers[channel] = setTimeout( + this.reconnectNow.bind(this, channel), + delayMs, + ); + } - break; - case 'subscription': - this.destroyChannel(channel, this); - this.subscription = 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. + * + * @access private + * @param {RedisConnectionChannel} channel + * @return {Promise} + */ + private async reconnectNow(channel: RedisConnectionChannel): Promise { + if (this.destroyed) { + this.reconnecting[channel] = false; - break; - } + return; + } - await this.connect(channel, this.options); - this.reconnectAttempts[channel] = 0; - this.reconnecting[channel] = false; + 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; + } - if (this.reconnectTimers[channel]) { - clearTimeout(this.reconnectTimers[channel] as any); - this.reconnectTimers[channel] = undefined; - } + await this.connect(channel, this.options); + this.reconnectAttempts[channel] = 0; + this.reconnecting[channel] = false; - 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 * @@ -997,7 +1273,7 @@ export class RedisQueue 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}`; } @@ -1006,15 +1282,12 @@ export class RedisQueue * Builds and returns connection error handler * * @access private - * @param {RedisQueue} context * @param {RedisConnectionChannel} channel * @return {(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}`); @@ -1023,7 +1296,7 @@ export class RedisQueue } this.logger.error( - `${context.name}: error connecting redis host ${ + `${this.name}: error connecting redis host ${ this.redisKey } on ${channel}, pid ${process.pid}:`, error, @@ -1032,7 +1305,7 @@ export class RedisQueue if ( error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || - context[channel]?.status !== 'ready' + this.connectionOf(channel)?.status !== 'ready' ) { this.scheduleReconnect(channel); } @@ -1043,23 +1316,18 @@ export class RedisQueue * Builds and returns redis connection close handler * * @access private - * @param {RedisQueue} context * @param {RedisConnectionChannel} channel - * @return {(...args: any[]) => any} + * @return {() => void} */ - private onCloseHandler( - context: RedisQueue, - channel: RedisConnectionChannel, - ): (...args: any[]) => any { + private onCloseHandler(channel: RedisConnectionChannel): () => void { this.verbose(`Redis ${channel} is closing...`); - // istanbul ignore next return () => { this.initialized = false; this.logger.warn( '%s: redis connection %s closed on host %s, pid %s!', - context.name, + this.name, channel, this.redisKey, process.pid, @@ -1082,7 +1350,6 @@ export class RedisQueue // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const [queue, data] = msg; - // istanbul ignore next if (!queue || queue !== this.key) { return this; } @@ -1093,11 +1360,10 @@ export class RedisQueue // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 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, ); } @@ -1110,14 +1376,15 @@ export class RedisQueue * @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'); @@ -1137,7 +1404,11 @@ export class RedisQueue */ 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, @@ -1145,17 +1416,31 @@ export class RedisQueue 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 * @@ -1173,7 +1458,7 @@ export class RedisQueue 'MATCH', `${this.options.prefix}:*:worker:*`, 'COUNT', - '1000', + SCAN_COUNT, ); cursor = data.shift() as string; @@ -1189,7 +1474,7 @@ export class RedisQueue this.emitError( 'OnSafeDelivery', 'safe queue message delivery problem', - err as unknown as Error, + err, ); this.cleanSafeCheckInterval(); @@ -1198,7 +1483,6 @@ export class RedisQueue } } - // istanbul ignore next /** * Process given keys from a message queue * @@ -1221,15 +1505,23 @@ export class RedisQueue 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 * @@ -1249,11 +1541,10 @@ export class RedisQueue 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 * @@ -1261,7 +1552,7 @@ export class RedisQueue */ private cleanSafeCheckInterval(): void { if (this.safeCheckInterval) { - clearInterval(this.safeCheckInterval as number); + clearInterval(this.safeCheckInterval); delete this.safeCheckInterval; } } @@ -1272,7 +1563,6 @@ export class RedisQueue * @access private * @returns {RedisQueue} */ - // istanbul ignore next private watch(): RedisQueue { if (!this.writer || !this.watcher || this.watcher.__ready__) { return this; @@ -1281,54 +1571,31 @@ export class RedisQueue try { this.writer .config('SET', 'notify-keyspace-events', 'Ex') - .catch(err => { - this.emitError( - 'OnConfig', - 'events config error', - err as unknown as Error, - ); - }); + .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.on('pmessage', this.onWatchMessage.bind(this)); this.watcher .psubscribe( '__keyevent@0__:expired', `${this.options.prefix}:delayed:*`, ) - .catch(err => { - this.verbose(`Error subscribing to watcher channel: ${err}`); - }); + .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; @@ -1336,6 +1603,27 @@ export class RedisQueue return this; } + /** + * A single safe-delivery maintenance tick: recovers messages from dead + * workers (when safe delivery is on) and prunes orphaned keys. + * + * @access private + * @return {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 * @@ -1351,9 +1639,12 @@ export class RedisQueue } const filter: RegExp = new RegExp( - this.options.prefix + + escapeRegExp(this.options.prefix || '') + ':' + - (this.options.cleanupFilter || '*').replace(/\*/g, '.*'), + escapeRegExp(this.options.cleanupFilter || '*').replace( + /\\\*/g, + '.*', + ), 'i', ); @@ -1373,11 +1664,23 @@ export class RedisQueue (name: string, i: number, a: string[]) => a.indexOf(name) === i, ); + // clients seen connected during the previous sweep get one + // sweep of grace: a client that is merely reconnecting (the + // backoff can reach tens of seconds) must not have its 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 + `Found connected keys: ${knownKeys .map(k => `"${k}"`) .join(', ')}`, ); @@ -1390,7 +1693,7 @@ export class RedisQueue this.options.cleanupFilter || '*' }`, 'COUNT', - '1000', + SCAN_COUNT, ); cursor = data.shift() as string; @@ -1401,7 +1704,7 @@ export class RedisQueue ...keys.filter( key => key !== this.lockKey && - connectedKeys.every( + knownKeys.every( connectedKey => key.indexOf(connectedKey) === -1, ), @@ -1429,7 +1732,6 @@ export class RedisQueue return this; } - // noinspection JSUnusedLocalSymbols /** * Unreliable but fast way of message handling by the queue */ @@ -1449,7 +1751,6 @@ export class RedisQueue this.process(msg); } } catch (err) { - // istanbul ignore next if ( err instanceof Error && err.message.match(/Stream connection ended/) @@ -1457,68 +1758,68 @@ export class RedisQueue 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}`; - - if (!this.reader || !this.writer) { - break; - } + 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, + ); - try { - await this.reader.brpoplpush(this.key, workerKey, 0); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (_) { - // istanbul ignore next - break; - } + while (true) { + if (!this.reader || !this.writer || this.destroyed) { + break; + } - const msgArr: any = await this.writer.lrange(workerKey, -1, 1); + const expire: number = + Date.now() + Number(this.options.safeDeliveryTtl); + const workerKey = `${key}:worker:${randomUUID()}:${expire}`; + let msg: string | null; - if (!msgArr || msgArr?.length !== 1) { - // noinspection ExceptionCaughtLocallyJS - throw new Error('Wrong messages count'); - } + try { + msg = await this.reader.blmove( + this.key, + workerKey, + 'RIGHT', + 'LEFT', + timeout, + ); + } catch { + // reader connection ended (stop/reconnect) + break; + } - // 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)); + } 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, - ); } } @@ -1528,7 +1829,6 @@ export class RedisQueue * @returns {RedisQueue} */ private read(): RedisQueue { - // istanbul ignore next if (!this.reader) { this.logger.error( `${this.name}: reader connection is not initialized, pid ${ @@ -1539,12 +1839,11 @@ export class RedisQueue 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; } @@ -1591,17 +1890,24 @@ export class RedisQueue 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 @@ -1620,7 +1926,6 @@ export class RedisQueue * * @returns {Promise} */ - // istanbul ignore next private async ownWatch(): Promise { const owned = await this.lock(); @@ -1629,9 +1934,8 @@ export class RedisQueue 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( 'EXISTS', @@ -1646,11 +1950,7 @@ export class RedisQueue ); } } catch (err) { - this.emitError( - 'OnScriptLoad', - 'script load error', - err as unknown as Error, - ); + this.emitError('OnScriptLoad', 'script load error', err); } } @@ -1660,33 +1960,21 @@ export class RedisQueue } } - // 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} - */ - 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(); - } + * @return {Promise} + */ + 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(); + } } /** @@ -1695,42 +1983,34 @@ export class RedisQueue * * @returns {Promise} */ - // istanbul ignore next private async initWatcher(): Promise { - return new Promise((resolve, reject) => { - void (async (): 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); - } - })(); - }); + 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/UDPWorker.ts b/src/UDPWorker.ts index 399abb8..da40993 100644 --- a/src/UDPWorker.ts +++ b/src/UDPWorker.ts @@ -31,7 +31,7 @@ import { import { createSocket, Socket } from 'dgram'; import { networkInterfaces } from 'os'; import { UDPClusterManagerOptions } from './UDPClusterManager'; -import { uuid } from './uuid'; +import { randomUUID } from 'crypto'; process.setMaxListeners(10000); @@ -118,7 +118,7 @@ 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); diff --git a/src/copyEventEmitter.ts b/src/helpers/copyEventEmitter.ts similarity index 94% rename from src/copyEventEmitter.ts rename to src/helpers/copyEventEmitter.ts index 20721b7..987da13 100644 --- a/src/copyEventEmitter.ts +++ b/src/helpers/copyEventEmitter.ts @@ -22,7 +22,7 @@ * to get commercial licensing options. */ import { EventEmitter } from 'events'; -import * as util from 'util'; +import { inspect } from 'util'; export function copyEventEmitter( source: EventEmitter & { @@ -39,7 +39,7 @@ export function copyEventEmitter( const listeners = source.rawListeners(event) as any[]; for (const originalListener of listeners) { - if (util.inspect(originalListener).includes('onceWrapper')) { + if (inspect(originalListener).includes('onceWrapper')) { const realListener = originalListener?.listener || originalListener; diff --git a/src/index.ts b/src/index.ts index 2a271bc..4a28626 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,30 +19,11 @@ * 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 './helpers'; export * from './IMQMode'; export * from './profile'; -export * from './uuid'; export * from './redis'; 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 5b08e54..6f3a284 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -24,7 +24,6 @@ import { ILogger } from '.'; export enum LogLevel { - // noinspection JSUnusedGlobalSymbols LOG = 'log', INFO = 'info', WARN = 'warn', @@ -48,7 +47,7 @@ export interface ProfileDecoratorOptions { } /** - * Checks if log level is set to proper value or returns default one + * Checks if the log level is set to the proper value or returns the default one * * @param {*} level * @return {LogLevel} @@ -79,7 +78,7 @@ export type AllowedTimeFormat = 'microseconds' | 'milliseconds' | 'seconds'; * * @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 @@ -87,7 +86,7 @@ export const IMQ_LOG_TIME = !!+(process.env.IMQ_LOG_TIME || 0); * * @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=[ @@ -167,7 +166,6 @@ export function logDebugInfo({ 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'; @@ -284,7 +282,6 @@ export function profile( } const self = this as any; - /* istanbul ignore next */ const className = self ? typeof self === 'function' ? self.name // static @@ -303,7 +300,6 @@ export function profile( start, }; - /* istanbul ignore next */ if (result && typeof (result as any).then === 'function') { // async call detected (result as any) diff --git a/src/redis.ts b/src/redis.ts index f7c47cf..708cf93 100644 --- a/src/redis.ts +++ b/src/redis.ts @@ -21,7 +21,6 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -/* tslint:disable */ import Redis from 'ioredis'; /** diff --git a/test/helpers/index.ts b/test/helpers/index.ts index 38af796..941666d 100644 --- a/test/helpers/index.ts +++ b/test/helpers/index.ts @@ -21,18 +21,4 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import { ILogger } from '../..'; - -/** - * 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; -} +export * from './makeLogger'; diff --git a/src/uuid.ts b/test/helpers/makeLogger.ts similarity index 66% rename from src/uuid.ts rename to test/helpers/makeLogger.ts index 0006e14..bbbb5bb 100644 --- a/src/uuid.ts +++ b/test/helpers/makeLogger.ts @@ -1,7 +1,5 @@ /*! - * Unified Unique ID Generator - * Based on solution inspired by Jeff Ward and the comments to it: - * @see http://stackoverflow.com/a/21963136/1511662 + * IMQ Unit Test Helpers * * I'm Queue Software Project * Copyright (C) 2025 imqueue.com @@ -23,17 +21,18 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import { randomUUID } from 'crypto'; +import { ILogger } from '../../src'; /** - * Generates and returns Unified Unique Identifier (RFC 4122 v4). + * Builds a fresh no-op logger suitable for spying on in unit tests. * - * Uses the cryptographically strong native generator, which is both faster - * and collision-safe under high load compared to a Math.random() based - * implementation. - * - * @returns {string} + * @return {ILogger} */ -export function uuid(): string { - return randomUUID(); +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/redis.ts b/test/mocks/redis.ts index 44fee50..738f68e 100644 --- a/test/mocks/redis.ts +++ b/test/mocks/redis.ts @@ -41,7 +41,6 @@ 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'; @@ -57,9 +56,8 @@ export class RedisClientMock extends EventEmitter { } } - // noinspection JSUnusedGlobalSymbols public end() {} - // noinspection JSUnusedGlobalSymbols + public quit() { return new Promise(resolve => resolve(undefined)); } @@ -68,7 +66,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,7 +73,6 @@ 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(); @@ -93,7 +89,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]) { @@ -158,7 +153,61 @@ export class RedisClientMock extends EventEmitter { } } - // 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 lrange( key: string, start: number, @@ -172,7 +221,6 @@ export class RedisClientMock extends EventEmitter { return result; } - // noinspection JSUnusedGlobalSymbols,JSMethodCanBeStatic public scan(...args: any[]): (string | string[])[] { const cb = args.pop(); const qs = RedisClientMock.__queues__; @@ -187,7 +235,6 @@ export class RedisClientMock extends EventEmitter { return result; } - // noinspection JSMethodCanBeStatic public script(...args: any[]): unknown { const cmd = args.shift(); const scriptOrHash = args.shift(); @@ -215,7 +262,6 @@ export class RedisClientMock extends EventEmitter { return [0]; } - // noinspection JSUnusedGlobalSymbols public client(...args: any[]): string | boolean { const self = RedisClientMock; const cmd = args.shift(); @@ -238,7 +284,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; @@ -246,7 +291,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)); @@ -257,13 +301,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; @@ -281,7 +323,6 @@ 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(); @@ -293,7 +334,6 @@ export class RedisClientMock extends EventEmitter { return true; } - // noinspection JSUnusedGlobalSymbols public disconnect(): boolean { delete RedisClientMock.__clientList[this.__name]; if (this.__rt) { @@ -303,23 +343,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)); } diff --git a/test/unit/ClusteredRedisQueue/AddServer.spec.ts b/test/unit/ClusteredRedisQueue/AddServer.spec.ts deleted file mode 100644 index 43b02ed..0000000 --- a/test/unit/ClusteredRedisQueue/AddServer.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * ClusteredRedisQueue.addServerWithQueueInitializing tests - * (default initializeQueue param branch + initializeQueue=false branch) - */ -import '../../mocks'; -import { describe, it } from 'node:test'; -import * as assert from 'node:assert/strict'; -import { ClusteredRedisQueue } from '../../../src'; -import { ClusterManager } from '../../../src/ClusterManager'; - -const server = { host: '127.0.0.1', port: 6380 }; - -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(); - }); -}); diff --git a/test/unit/ClusteredRedisQueue/ClusteredRedisQueue.spec.ts b/test/unit/ClusteredRedisQueue/ClusteredRedisQueue.spec.ts deleted file mode 100644 index a9fdbdc..0000000 --- a/test/unit/ClusteredRedisQueue/ClusteredRedisQueue.spec.ts +++ /dev/null @@ -1,398 +0,0 @@ -/*! - * ClusteredRedisQueue Unit Tests (core behavior + EventEmitter proxy methods) - * - * 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 * as assert from 'node:assert/strict'; -import { ClusteredRedisQueue } 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, - }, - ], -}; - -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); - }); -}); diff --git a/test/unit/ClusteredRedisQueue/Initialize.spec.ts b/test/unit/ClusteredRedisQueue/Initialize.spec.ts deleted file mode 100644 index 7bcfede..0000000 --- a/test/unit/ClusteredRedisQueue/Initialize.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/*! - * Additional tests for ClusteredRedisQueue.initializeQueue branches - */ -import '../../mocks'; -import { describe, it, mock, Mock } from 'node:test'; -import * as assert from 'node:assert/strict'; -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: 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(); - }); -}); diff --git a/test/unit/ClusteredRedisQueue/MatchServers.spec.ts b/test/unit/ClusteredRedisQueue/MatchServers.spec.ts deleted file mode 100644 index 8ea0681..0000000 --- a/test/unit/ClusteredRedisQueue/MatchServers.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -/*! - * Tests for ClusteredRedisQueue.matchServers combinations - */ -import '../../mocks'; -import { describe, it } from 'node:test'; -import * as assert from 'node:assert/strict'; -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', () => { - 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, - ); - }); -}); diff --git a/test/unit/CopyEventEmitter.spec.ts b/test/unit/CopyEventEmitter.spec.ts deleted file mode 100644 index 3f7641d..0000000 --- a/test/unit/CopyEventEmitter.spec.ts +++ /dev/null @@ -1,246 +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 '../mocks'; -import { describe, it } from 'node:test'; -import * as assert from 'node:assert/strict'; -import { EventEmitter } from 'events'; -import { copyEventEmitter } from '../../src'; - -describe('copyEventEmitter()', () => { - const eventName = 'test'; - - it('should have the same number of listeners', () => { - const source = new EventEmitter(); - const target = new EventEmitter(); - - source.on('test', () => {}); - copyEventEmitter(source, target); - - assert.equal(target.listenerCount('test'), 1); - }); - - it('should copy the same listener on', () => { - const source = new EventEmitter(); - const target = new EventEmitter(); - - source.on(eventName, () => {}); - - copyEventEmitter(source, target); - - const targetListener = target.listeners(eventName)[0]; - const sourceListener = source.listeners(eventName)[0]; - - assert.equal(targetListener, sourceListener); - }); - - it('should copy the same listener once', () => { - const source = new EventEmitter(); - const target = new EventEmitter(); - - source.once(eventName, () => {}); - copyEventEmitter(source, target); - - const targetListener = target.listeners(eventName)[0]; - const sourceListener = source.listeners(eventName)[0]; - - assert.equal(targetListener, sourceListener); - }); - - it('should set same max listeners count', () => { - const source = new EventEmitter(); - const target = new EventEmitter(); - - source.setMaxListeners(25); - copyEventEmitter(source, target); - - const targetListenersCount = target.getMaxListeners(); - const sourceListenersCount = source.getMaxListeners(); - - assert.equal(targetListenersCount, sourceListenersCount); - }); - - it('should handle listeners without listener property', () => { - const source = new EventEmitter(); - const target = new EventEmitter(); - - // Create a mock listener that looks like onceWrapper but has no listener property - const mockListener = function () {}; - Object.defineProperty(mockListener, 'toString', { - 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) => { - if (obj === mockListener) { - return 'function onceWrapper() { ... }'; - } - return originalInspect(obj); - }; - - copyEventEmitter(source, target); - - // Restore original inspect - require('util').inspect = originalInspect; - - assert.equal(target.listenerCount(eventName), 1); - }); - - it('should handle source without _maxListeners property', () => { - const source = new EventEmitter(); - const target = new EventEmitter(); - - // Remove _maxListeners property to test the undefined case - delete (source as any)._maxListeners; - - source.on(eventName, () => {}); - copyEventEmitter(source, target); - - assert.equal(target.listenerCount(eventName), 1); - }); - - it('should handle once listeners with listener property', () => { - const source = new EventEmitter(); - const target = new EventEmitter(); - - const originalListener = () => {}; - source.once(eventName, originalListener); - - copyEventEmitter(source, target); - - 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 () {}; - mockListener.listener = 0; // falsy value present - const originalInspect = require('util').inspect; - require('util').inspect = (obj: any) => { - if (obj === mockListener) { - return 'function onceWrapper() { ... }'; - } - return originalInspect(obj); - }; - - source.on(eventName, mockListener as any); - copyEventEmitter(source, target); - - // Restore original inspect - require('util').inspect = originalInspect; - - 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 () {}; - mockListener.listener = undefined; // explicitly undefined - const originalInspect = require('util').inspect; - require('util').inspect = (obj: any) => { - if (obj === mockListener) { - return 'function onceWrapper() { ... }'; - } - return originalInspect(obj); - }; - - source.on(eventName, mockListener as any); - copyEventEmitter(source, target); - - // Restore original inspect - require('util').inspect = originalInspect; - - assert.equal(target.listenerCount(eventName), 1); - }); - - it('should handle onceWrapper-like listener with truthy listener property', () => { - const source = new EventEmitter(); - const target = new EventEmitter(); - - // Create a mock listener that looks like onceWrapper and has a truthy listener property - let called = 0; - const realListener = () => { - called++; - }; - const mockListener: any = function () {}; - mockListener.listener = realListener; // truthy function - - const originalInspect = require('util').inspect; - require('util').inspect = (obj: any) => { - if (obj === mockListener) { - return 'function onceWrapper() { ... }'; - } - return originalInspect(obj); - }; - - source.on(eventName, mockListener as any); - copyEventEmitter(source, target); - - // Restore original inspect - require('util').inspect = originalInspect; - - // Ensure the listener was attached via once() and is callable exactly once - assert.equal(target.listenerCount(eventName), 1); - target.emit(eventName); - target.emit(eventName); - assert.equal(called, 1); - }); - - it('should handle onceWrapper path when originalListener is undefined', () => { - const source: any = { - eventNames: () => [eventName], - rawListeners: () => [undefined], - getMaxListeners: () => 0, - setMaxListeners: () => {}, - }; - const onceCalls: any[] = []; - const target: any = { - once: (ev: any, listener: any) => { - onceCalls.push([ev, listener]); - }, - on: () => {}, - }; - const originalInspect = require('util').inspect; - require('util').inspect = (obj: any) => { - if (typeof obj === 'undefined') { - return 'function onceWrapper() { ... }'; - } - return originalInspect(obj); - }; - - copyEventEmitter(source as any, target as any); - - // Restore original inspect - require('util').inspect = originalInspect; - - assert.equal(onceCalls.length, 1); - assert.equal(onceCalls[0][0], eventName); - assert.equal(onceCalls[0][1], undefined); - }); -}); diff --git a/test/unit/IMQ.spec.ts b/test/unit/IMQ.spec.ts deleted file mode 100644 index be59d42..0000000 --- a/test/unit/IMQ.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * IMessageQueue 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 } from 'node:test'; -import * as assert from 'node:assert/strict'; -import IMQ, { RedisQueue, ClusteredRedisQueue } from '../..'; - -describe('IMQ', () => { - it('should be a class', () => { - assert.equal(typeof IMQ, 'function'); - }); - - describe('create()', () => { - it('should return proper object', () => { - assert.ok( - IMQ.create('IMQUnitTest', { vendor: 'Redis' }) instanceof - RedisQueue, - ); - }); - - it('should throw if unknown vendor specified', () => { - assert.throws( - () => IMQ.create('IMQUnitTest', { vendor: 'JudgmentDay' }), - Error, - ); - }); - - it('should allow to be called with no options', () => { - assert.doesNotThrow(() => IMQ.create('IMQUnitTest')); - }); - - it('should return clustered object if cluster options passed', () => { - assert.ok( - IMQ.create('IMQUnitTest', { - vendor: 'Redis', - cluster: [ - { - host: 'localhost', - port: 1111, - }, - { - host: 'localhost', - port: 2222, - }, - ], - }) instanceof ClusteredRedisQueue, - ); - }); - }); -}); diff --git a/test/unit/IMessageQueue.spec.ts b/test/unit/IMessageQueue.spec.ts index a1a7eb7..9dc7cfd 100644 --- a/test/unit/IMessageQueue.spec.ts +++ b/test/unit/IMessageQueue.spec.ts @@ -25,8 +25,6 @@ import * as assert from 'node:assert/strict'; import { EventEmitter as IMQEventEmitter } from '../../src'; import { EventEmitter as NodeEventEmitter } from '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 diff --git a/test/unit/Profile/Decorator.spec.ts b/test/unit/Profile/Decorator.spec.ts deleted file mode 100644 index 047da47..0000000 --- a/test/unit/Profile/Decorator.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -/*! - * profile decorator branch coverage & async rejection path unit tests - */ -import '../../mocks'; -import { describe, it, mock } from 'node:test'; -import * as assert from 'node:assert/strict'; -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); - 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 (e) { - // expected - } - // allow microtask queue - await new Promise(res => setTimeout(res, 0)); - assert.ok(logger.info.mock.callCount() > 0); - }); -}); diff --git a/test/unit/Profile/Profile.spec.ts b/test/unit/Profile/Profile.spec.ts deleted file mode 100644 index 7fcfbdc..0000000 --- a/test/unit/Profile/Profile.spec.ts +++ /dev/null @@ -1,297 +0,0 @@ -/*! - * profile() Function Unit Tests (merged with additional branch coverage) - * - * 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 * as 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 (err) { - 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; - let warn: Mock; - let info: Mock; - - beforeEach(() => { - log = mock.method(logger, 'log'); - error = mock.method(logger, 'error'); - warn = mock.method(logger, 'warn'); - info = 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); - }); -}); diff --git a/test/unit/RedisQueue/Cleanup.spec.ts b/test/unit/RedisQueue/Cleanup.spec.ts deleted file mode 100644 index 2901f6c..0000000 --- a/test/unit/RedisQueue/Cleanup.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -/*! - * Additional RedisQueue tests: processCleanup catch branch - */ -import '../../mocks'; -import { describe, it, afterEach, mock, Mock } from 'node:test'; -import * as assert from 'node:assert/strict'; -import { RedisQueue } from '../../../src'; -import { makeLogger } from '../../helpers'; - -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(); - }); -}); diff --git a/test/unit/RedisQueue/Connect.spec.ts b/test/unit/RedisQueue/Connect.spec.ts deleted file mode 100644 index 7eab7bd..0000000 --- a/test/unit/RedisQueue/Connect.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -/*! - * Additional RedisQueue tests: connect() option fallbacks branches - */ -import '../../mocks'; -import { describe, it } from 'node:test'; -import * as assert from 'node:assert/strict'; -import { RedisQueue, IMQMode } from '../../../src'; -import { makeLogger } from '../../helpers'; - -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(); - }); -}); diff --git a/test/unit/RedisQueue/ProcessCleanup.spec.ts b/test/unit/RedisQueue/ProcessCleanup.spec.ts deleted file mode 100644 index bc3b739..0000000 --- a/test/unit/RedisQueue/ProcessCleanup.spec.ts +++ /dev/null @@ -1,165 +0,0 @@ -/*! - * Additional RedisQueue tests: processCleanup branches (connectedKeys filter, - * extra prefix, multi-scan/no-delete, null-match and falsy cleanupFilter) - */ -import '../../mocks'; -import { describe, it, afterEach, mock, Mock } from 'node:test'; -import * as assert from 'node:assert/strict'; -import { RedisQueue, uuid } from '../../../src'; -import { RedisClientMock } from '../../mocks'; - -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(); - }); -}); diff --git a/test/unit/RedisQueue/ProcessDelayed.spec.ts b/test/unit/RedisQueue/ProcessDelayed.spec.ts deleted file mode 100644 index 8bfd5cd..0000000 --- a/test/unit/RedisQueue/ProcessDelayed.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/*! - * Additional RedisQueue tests: processDelayed() catch branch - */ -import '../../mocks'; -import { describe, it, afterEach, mock, Mock } from 'node:test'; -import * as assert from 'node:assert/strict'; -import { RedisQueue } from '../../../src'; -import { makeLogger } from '../../helpers'; - -describe('RedisQueue.processDelayed extra branches', () => { - afterEach(() => { - mock.restoreAll(); - }); - - 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: Mock = mock.method( - (RedisQueue as any).prototype, - 'emitError', - () => undefined, - ); - - // Temporarily drop writer to force a synchronous error in processDelayed - const originalWriter = rq.writer; - rq['writer'] = undefined; - - await rq['processDelayed'](rq.key); - - assert.ok(emitErrorStub.mock.callCount() > 0); - assert.equal( - emitErrorStub.mock.calls[0].arguments[0], - 'OnProcessDelayed', - ); - - // Restore writer and cleanup - rq['writer'] = originalWriter; - await rq.destroy(); - }); -}); diff --git a/test/unit/RedisQueue/Publish.spec.ts b/test/unit/RedisQueue/Publish.spec.ts deleted file mode 100644 index 6161654..0000000 --- a/test/unit/RedisQueue/Publish.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -/*! - * Additional RedisQueue tests: publish() branches - */ -import '../../mocks'; -import { describe, it, mock } from 'node:test'; -import * as assert from 'node:assert/strict'; -import { RedisQueue, IMQMode } from '../../../src'; -import { makeLogger } from '../../helpers'; - -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); - }); -}); diff --git a/test/unit/RedisQueue/RedisQueue.spec.ts b/test/unit/RedisQueue/RedisQueue.spec.ts deleted file mode 100644 index dfd34b3..0000000 --- a/test/unit/RedisQueue/RedisQueue.spec.ts +++ /dev/null @@ -1,367 +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 { describe, it, beforeEach } from 'node:test'; -import * as assert from 'node:assert/strict'; -import { RedisQueue, uuid, IMQMode } from '../../../src'; -import Redis from 'ioredis'; - -process.setMaxListeners(100); - -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 (err) { - 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 trigger an error in case of redis error', (t, 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, - }); - - rq.on('error', function (e) { - assert.equal((e as any).message, 'Wrong messages count'); - Redis.prototype.lrange = lrange; - if (!wasDone) { - rq.destroy().catch(); - 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(); - 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(); - }); - }); -}); diff --git a/test/unit/RedisQueue/Send.spec.ts b/test/unit/RedisQueue/Send.spec.ts deleted file mode 100644 index de59a30..0000000 --- a/test/unit/RedisQueue/Send.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -/*! - * Additional RedisQueue tests: send() extra branches and worker-only mode - */ -import '../../mocks'; -import { describe, it, mock } from 'node:test'; -import * as assert from 'node:assert/strict'; -import { RedisQueue, IMQMode } from '../../../src'; -import { makeLogger } from '../../helpers'; - -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 - const startStub = 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); - }); -}); diff --git a/test/unit/RedisQueue/Unsubscribe.spec.ts b/test/unit/RedisQueue/Unsubscribe.spec.ts deleted file mode 100644 index a7165f2..0000000 --- a/test/unit/RedisQueue/Unsubscribe.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/*! - * Additional RedisQueue tests: unsubscribe() cleanup path - */ -import '../../mocks'; -import { describe, it, mock, Mock } from 'node:test'; -import * as assert from 'node:assert/strict'; -import { RedisQueue } from '../../../src'; -import { makeLogger } from '../../helpers'; - -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); - }); -}); diff --git a/test/unit/UDPClusterManager/DestroySocket.spec.ts b/test/unit/UDPClusterManager/DestroySocket.spec.ts deleted file mode 100644 index e70a4d6..0000000 --- a/test/unit/UDPClusterManager/DestroySocket.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -/*! - * UDPClusterManager.destroyWorker() behavior tests aligned with implementation - */ -import '../../mocks'; -import { describe, it } from 'node:test'; -import * as assert from 'node:assert/strict'; -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< - string, - any - >; - 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); - - assert.equal(terminated, true); - assert.equal(workers[key], undefined); - }); -}); diff --git a/test/unit/UDPClusterManager/UDPClusterManager.spec.ts b/test/unit/UDPClusterManager/UDPClusterManager.spec.ts deleted file mode 100644 index 600c1b1..0000000 --- a/test/unit/UDPClusterManager/UDPClusterManager.spec.ts +++ /dev/null @@ -1,236 +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. - * - * UDPClusterManager unit tests (merged: main suite + missing branches coverage). - */ -import '../../mocks'; -import { describe, it, afterEach, mock } from 'node:test'; -import * as 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 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(); - - assert.equal( - Object.keys((UDPClusterManager as any).sockets).length, - 0, - ); - }); - }); -}); - -describe('UDPClusterManager - cover remaining branches', () => { - it('destroySocket should call socket.unref() when socket is present', async () => { - // Prepare fake socket with unref - const unref = mock.fn(); - const removeAll = mock.fn(); - 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); - assert.equal(unref.mock.callCount() > 0, true); - assert.equal((UDPClusterManager as any).sockets[key], undefined); - }); - - it('destroySocket should work when socket.unref() is absent (optional chaining negative branch)', async () => { - const removeAll = mock.fn(); - 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 - assert.equal((UDPClusterManager as any).sockets[key], undefined); - }); -}); diff --git a/test/unit/Uuid.spec.ts b/test/unit/Uuid.spec.ts deleted file mode 100644 index 4d87310..0000000 --- a/test/unit/Uuid.spec.ts +++ /dev/null @@ -1,72 +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 { describe, it } from 'node:test'; -import * as assert from 'node:assert/strict'; -import { uuid } from '../..'; - -describe('uuid()', () => { - 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++) { - assert.equal(regex.test(uuid()), 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; - } - - assert.equal(Object.keys(keyStore).length, steps); - }); - - it('should generate 100,000 identifiers in less than a second', () => { - const start = Date.now(); - - for (let i = 0; i < steps; i++) { - uuid(); - } - - assert.ok(Date.now() - start < 1000); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index b81be21..741cae9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,24 @@ { "compilerOptions": { - "module": "node16", - "moduleResolution": "node16", - "target": "es2022", - "lib": [ - "es2023" - ], + "target": "es2023", + "lib": ["es2023"], + "moduleDetection": "force", + + "module": "nodenext", + "moduleResolution": "nodenext", + "resolveJsonModule": true, + "esModuleInterop": true, + + "isolatedModules": true, + "declaration": true, "sourceMap": true, "inlineSources": true, "removeComments": false, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, + "newLine": "lf", + "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "experimentalDecorators": false, - "emitDecoratorMetadata": false + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true } } From 362d7f638afd48cf86f57e9210ce2b8aec218143 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Mon, 6 Jul 2026 21:41:00 +0200 Subject: [PATCH 04/20] fix:: added files --- src/helpers/buildOptions.ts | 38 + src/helpers/envInt.ts | 36 + src/helpers/escapeRegExp.ts | 32 + src/helpers/index.ts | 31 + src/helpers/pack.ts | 34 + src/helpers/randomInt.ts | 33 + src/helpers/sha1.ts | 38 + src/helpers/unpack.ts | 34 + test/unit/ClusteredRedisQueue.spec.ts | 536 ++++++++++ test/unit/RedisQueue.spec.ts | 1116 ++++++++++++++++++++ test/unit/UDPClusterManager.spec.ts | 272 +++++ test/unit/helpers/copyEventEmitter.spec.ts | 246 +++++ test/unit/index.spec.ts | 71 ++ test/unit/profile.spec.ts | 346 ++++++ 14 files changed, 2863 insertions(+) create mode 100644 src/helpers/buildOptions.ts create mode 100644 src/helpers/envInt.ts create mode 100644 src/helpers/escapeRegExp.ts create mode 100644 src/helpers/index.ts create mode 100644 src/helpers/pack.ts create mode 100644 src/helpers/randomInt.ts create mode 100644 src/helpers/sha1.ts create mode 100644 src/helpers/unpack.ts create mode 100644 test/unit/ClusteredRedisQueue.spec.ts create mode 100644 test/unit/RedisQueue.spec.ts create mode 100644 test/unit/UDPClusterManager.spec.ts create mode 100644 test/unit/helpers/copyEventEmitter.spec.ts create mode 100644 test/unit/index.spec.ts create mode 100644 test/unit/profile.spec.ts diff --git a/src/helpers/buildOptions.ts b/src/helpers/buildOptions.ts new file mode 100644 index 0000000..411f089 --- /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 + * @return {T} + */ +export function buildOptions( + defaultOptions: T, + givenOptions?: Partial, +): T { + return Object.assign({}, defaultOptions, givenOptions); +} diff --git a/src/helpers/envInt.ts b/src/helpers/envInt.ts new file mode 100644 index 0000000..c291a71 --- /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 + * @return {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..99fda09 --- /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 + * @return {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..c3a0f08 --- /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 'zlib'; + +/** + * Compress given data and returns binary string + * + * @param {any} data + * @returns {string} + */ +export function pack(data: any): 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..c0d3c4f --- /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 '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..7136370 --- /dev/null +++ b/src/helpers/unpack.ts @@ -0,0 +1,34 @@ +/*! + * 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 'zlib'; + +/** + * Decompress binary string and returns plain data + * + * @param {string} data + * @returns {any} + */ +export function unpack(data: string): any { + return JSON.parse(gunzipSync(Buffer.from(data, 'binary')).toString()); +} diff --git a/test/unit/ClusteredRedisQueue.spec.ts b/test/unit/ClusteredRedisQueue.spec.ts new file mode 100644 index 0000000..0842680 --- /dev/null +++ b/test/unit/ClusteredRedisQueue.spec.ts @@ -0,0 +1,536 @@ +/*! + * 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 * as 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); + }); +}); + +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, + ); + }); +}); diff --git a/test/unit/RedisQueue.spec.ts b/test/unit/RedisQueue.spec.ts new file mode 100644 index 0000000..7e53100 --- /dev/null +++ b/test/unit/RedisQueue.spec.ts @@ -0,0 +1,1116 @@ +/*! + * 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 { describe, it, beforeEach, afterEach, mock, Mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { RedisQueue, IMQMode, sha1, escapeRegExp } from '../../src'; +import { logger, RedisClientMock } from '../mocks'; +import { randomUUID as uuid } from 'crypto'; +import Redis from 'ioredis'; +import { makeLogger } from '../helpers/makeLogger'; + +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 (err) { + 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('send() rejects on write failure when awaitWrites is enabled', async t => { + const logger = makeLogger(); + const rq: any = new RedisQueue( + 'AwaitW', + { logger, awaitWrites: true }, + IMQMode.PUBLISHER, + ); + await rq.start(); + t.after(() => rq.destroy().catch(() => undefined)); + + mock.method(rq.writer, 'lpush', () => + Promise.reject(new Error('write failed')), + ); + + await assert.rejects(rq.send('AwaitW', { a: 1 }), /write failed/); + }); + + 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 + const startStub = 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); + }); +}); diff --git a/test/unit/UDPClusterManager.spec.ts b/test/unit/UDPClusterManager.spec.ts new file mode 100644 index 0000000..77e0bfd --- /dev/null +++ b/test/unit/UDPClusterManager.spec.ts @@ -0,0 +1,272 @@ +/*! + * 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 } from 'node:test'; +import * as 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 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(); + + assert.equal( + Object.keys((UDPClusterManager as any).sockets).length, + 0, + ); + }); + }); +}); + +describe('UDPClusterManager - cover remaining branches', () => { + it('destroySocket should call socket.unref() when socket is present', async () => { + // Prepare fake socket with unref + const unref = mock.fn(); + const removeAll = mock.fn(); + 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); + assert.equal(unref.mock.callCount() > 0, true); + assert.equal((UDPClusterManager as any).sockets[key], undefined); + }); + + it('destroySocket should work when socket.unref() is absent (optional chaining negative branch)', async () => { + const removeAll = mock.fn(); + 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 + assert.equal((UDPClusterManager as any).sockets[key], undefined); + }); +}); + +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: () => {}, + once: (event: string, cb: Function) => { + if (event === 'message') { + setImmediate(() => cb({ type: 'stopped' })); + } + }, + terminate: () => { + terminated = true; + }, + }; + + workers[key] = fakeWorker; + await destroy(key, fakeWorker); + + assert.equal(terminated, true); + assert.equal(workers[key], undefined); + }); +}); diff --git a/test/unit/helpers/copyEventEmitter.spec.ts b/test/unit/helpers/copyEventEmitter.spec.ts new file mode 100644 index 0000000..f4caee3 --- /dev/null +++ b/test/unit/helpers/copyEventEmitter.spec.ts @@ -0,0 +1,246 @@ +/*! + * 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 } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { EventEmitter } from 'events'; +import { copyEventEmitter } from '../../../src'; + +describe('copyEventEmitter()', () => { + const eventName = 'test'; + + it('should have the same number of listeners', () => { + const source = new EventEmitter(); + const target = new EventEmitter(); + + source.on('test', () => {}); + copyEventEmitter(source, target); + + assert.equal(target.listenerCount('test'), 1); + }); + + it('should copy the same listener on', () => { + const source = new EventEmitter(); + const target = new EventEmitter(); + + source.on(eventName, () => {}); + + copyEventEmitter(source, target); + + const targetListener = target.listeners(eventName)[0]; + const sourceListener = source.listeners(eventName)[0]; + + assert.equal(targetListener, sourceListener); + }); + + it('should copy the same listener once', () => { + const source = new EventEmitter(); + const target = new EventEmitter(); + + source.once(eventName, () => {}); + copyEventEmitter(source, target); + + const targetListener = target.listeners(eventName)[0]; + const sourceListener = source.listeners(eventName)[0]; + + assert.equal(targetListener, sourceListener); + }); + + it('should set same max listeners count', () => { + const source = new EventEmitter(); + const target = new EventEmitter(); + + source.setMaxListeners(25); + copyEventEmitter(source, target); + + const targetListenersCount = target.getMaxListeners(); + const sourceListenersCount = source.getMaxListeners(); + + assert.equal(targetListenersCount, sourceListenersCount); + }); + + it('should handle listeners without listener property', () => { + const source = new EventEmitter(); + const target = new EventEmitter(); + + // Create a mock listener that looks like onceWrapper but has no listener property + const mockListener = function () {}; + Object.defineProperty(mockListener, 'toString', { + 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) => { + if (obj === mockListener) { + return 'function onceWrapper() { ... }'; + } + return originalInspect(obj); + }; + + copyEventEmitter(source, target); + + // Restore original inspect + require('util').inspect = originalInspect; + + assert.equal(target.listenerCount(eventName), 1); + }); + + it('should handle source without _maxListeners property', () => { + const source = new EventEmitter(); + const target = new EventEmitter(); + + // Remove _maxListeners property to test the undefined case + delete (source as any)._maxListeners; + + source.on(eventName, () => {}); + copyEventEmitter(source, target); + + assert.equal(target.listenerCount(eventName), 1); + }); + + it('should handle once listeners with listener property', () => { + const source = new EventEmitter(); + const target = new EventEmitter(); + + const originalListener = () => {}; + source.once(eventName, originalListener); + + copyEventEmitter(source, target); + + 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 () {}; + mockListener.listener = 0; // falsy value present + const originalInspect = require('util').inspect; + require('util').inspect = (obj: any) => { + if (obj === mockListener) { + return 'function onceWrapper() { ... }'; + } + return originalInspect(obj); + }; + + source.on(eventName, mockListener as any); + copyEventEmitter(source, target); + + // Restore original inspect + require('util').inspect = originalInspect; + + 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 () {}; + mockListener.listener = undefined; // explicitly undefined + const originalInspect = require('util').inspect; + require('util').inspect = (obj: any) => { + if (obj === mockListener) { + return 'function onceWrapper() { ... }'; + } + return originalInspect(obj); + }; + + source.on(eventName, mockListener as any); + copyEventEmitter(source, target); + + // Restore original inspect + require('util').inspect = originalInspect; + + assert.equal(target.listenerCount(eventName), 1); + }); + + it('should handle onceWrapper-like listener with truthy listener property', () => { + const source = new EventEmitter(); + const target = new EventEmitter(); + + // Create a mock listener that looks like onceWrapper and has a truthy listener property + let called = 0; + const realListener = () => { + called++; + }; + const mockListener: any = function () {}; + mockListener.listener = realListener; // truthy function + + const originalInspect = require('util').inspect; + require('util').inspect = (obj: any) => { + if (obj === mockListener) { + return 'function onceWrapper() { ... }'; + } + return originalInspect(obj); + }; + + source.on(eventName, mockListener as any); + copyEventEmitter(source, target); + + // Restore original inspect + require('util').inspect = originalInspect; + + // Ensure the listener was attached via once() and is callable exactly once + assert.equal(target.listenerCount(eventName), 1); + target.emit(eventName); + target.emit(eventName); + assert.equal(called, 1); + }); + + it('should handle onceWrapper path when originalListener is undefined', () => { + const source: any = { + eventNames: () => [eventName], + rawListeners: () => [undefined], + getMaxListeners: () => 0, + setMaxListeners: () => {}, + }; + const onceCalls: any[] = []; + const target: any = { + once: (ev: any, listener: any) => { + onceCalls.push([ev, listener]); + }, + on: () => {}, + }; + const originalInspect = require('util').inspect; + require('util').inspect = (obj: any) => { + if (typeof obj === 'undefined') { + return 'function onceWrapper() { ... }'; + } + return originalInspect(obj); + }; + + copyEventEmitter(source as any, target as any); + + // Restore original inspect + require('util').inspect = originalInspect; + + assert.equal(onceCalls.length, 1); + assert.equal(onceCalls[0][0], eventName); + assert.equal(onceCalls[0][1], undefined); + }); +}); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts new file mode 100644 index 0000000..be59d42 --- /dev/null +++ b/test/unit/index.spec.ts @@ -0,0 +1,71 @@ +/*! + * IMessageQueue 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 } from 'node:test'; +import * as assert from 'node:assert/strict'; +import IMQ, { RedisQueue, ClusteredRedisQueue } from '../..'; + +describe('IMQ', () => { + it('should be a class', () => { + assert.equal(typeof IMQ, 'function'); + }); + + describe('create()', () => { + it('should return proper object', () => { + assert.ok( + IMQ.create('IMQUnitTest', { vendor: 'Redis' }) instanceof + RedisQueue, + ); + }); + + it('should throw if unknown vendor specified', () => { + assert.throws( + () => IMQ.create('IMQUnitTest', { vendor: 'JudgmentDay' }), + Error, + ); + }); + + it('should allow to be called with no options', () => { + assert.doesNotThrow(() => IMQ.create('IMQUnitTest')); + }); + + it('should return clustered object if cluster options passed', () => { + 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..0e2408a --- /dev/null +++ b/test/unit/profile.spec.ts @@ -0,0 +1,346 @@ +/*! + * 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 * as 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 (err) { + 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; + let warn: Mock; + let info: Mock; + + beforeEach(() => { + log = mock.method(logger, 'log'); + error = mock.method(logger, 'error'); + warn = mock.method(logger, 'warn'); + info = 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 (e) { + // expected + } + // allow microtask queue + await new Promise(res => setTimeout(res, 0)); + assert.ok(logger.info.mock.callCount() > 0); + }); +}); From 430d91f53810e0c47dcc66ef8bb4ce38792bdd85 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Mon, 6 Jul 2026 23:31:52 +0200 Subject: [PATCH 05/20] fix: issues after inspections in clustered queue implementation, types, grammar --- README.md | 128 ++++++++------- benchmark/affinity.ts | 4 +- benchmark/index.ts | 107 ++++++++----- benchmark/redis-test.ts | 18 +-- src/ClusterManager.ts | 6 +- src/ClusteredRedisQueue.ts | 136 ++++++++++++---- src/IMessageQueue.ts | 177 +++++++++------------ src/RedisQueue.ts | 145 +++++++++-------- src/UDPClusterManager.ts | 132 ++++++++++----- src/UDPWorker.ts | 19 +-- src/helpers/buildOptions.ts | 2 +- src/helpers/envInt.ts | 2 +- src/helpers/escapeRegExp.ts | 2 +- src/helpers/pack.ts | 8 +- src/helpers/unpack.ts | 12 +- src/index.ts | 2 +- src/profile.ts | 163 +++++++++++-------- src/redis.ts | 2 +- test/mocks/os.ts | 2 +- test/mocks/redis.ts | 4 +- test/unit/ClusterManager.spec.ts | 2 +- test/unit/ClusteredRedisQueue.spec.ts | 2 +- test/unit/IMessageQueue.spec.ts | 2 +- test/unit/RedisQueue.spec.ts | 2 +- test/unit/UDPClusterManager.spec.ts | 2 +- test/unit/helpers/copyEventEmitter.spec.ts | 2 +- test/unit/index.spec.ts | 2 +- test/unit/profile.spec.ts | 2 +- 28 files changed, 632 insertions(+), 455 deletions(-) diff --git a/README.md b/README.md index 9cfc30b..d33e73a 100644 --- a/README.md +++ b/README.md @@ -2,64 +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. Redis server 6.2+ is required (the queue relies on `LMOVE`/`BLMOVE` -commands for safe message delivery). +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 () => { @@ -91,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] @@ -129,41 +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 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 +```bash git clone git@github.com:imqueue/core.git cd core npm install npm test -~~~ +``` To produce a coverage report use: -~~~bash +```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 322b500..e941feb 100644 --- a/benchmark/affinity.ts +++ b/benchmark/affinity.ts @@ -22,10 +22,10 @@ * to get commercial licensing options. */ import { execSync as exec } from 'child_process'; -import * as os from 'os'; +import { platform } from 'os'; export function setAffinity(cpu: number) { - if (os.platform() === 'linux') { + if (platform() === 'linux') { exec(`taskset -cp ${cpu} ${process.pid}`); } } diff --git a/benchmark/index.ts b/benchmark/index.ts index 0ca830a..c452629 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -21,9 +21,10 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ +import cluster from 'cluster'; import { execSync as exec } from 'child_process'; -import * as os from 'os'; -import * as fs from 'fs'; +import { arch, cpus, freemem, platform, totalmem } from 'os'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { parseArgs } from 'node:util'; import { run } from './redis-test'; import { resolve } from 'path'; @@ -31,10 +32,8 @@ import { randomUUID as uuid } from 'crypto'; import { AnyJson } from '..'; import { setAffinity } from './affinity'; -const cluster: any = require('cluster'); - /** - * Command line options: canonical long name -> parseArgs config + description + * Command line options: canonical long name -> parseArgs config and description * used to render `--help`. */ const OPTIONS: { @@ -127,7 +126,7 @@ if (ARGV.help) { 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.messages) || 10000; @@ -142,7 +141,7 @@ let SAMPLE_MESSAGE: AnyJson; if (ARGV['example-message']) { try { SAMPLE_MESSAGE = JSON.parse( - fs.readFileSync(ARGV['example-message'] + '').toString(), + readFileSync(ARGV['example-message'] + '').toString(), ); if (MSG_MULTIPLIER) { @@ -151,7 +150,7 @@ if (ARGV['example-message']) { () => SAMPLE_MESSAGE, ); } - } catch (err) { + } catch { console.warn( 'Given example message is invalid, ' + 'proceeding test execution with with standard ' + @@ -178,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; @@ -201,14 +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, %']; @@ -232,14 +235,34 @@ function buildStats({ metrics, memusage }: any): MachineStats { 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: { @@ -252,8 +275,8 @@ function buildChartConfig(id: string, stats: any[]) { categories: stats[0] .slice(1) .map( - (v: any, i: number) => - (((i * 100) / 1000).toFixed(1) + 's') as any, + (_: any, i: number) => + (((i * 100) / 1000).toFixed(1) + 's'), ), tick: { centered: true, @@ -274,14 +297,14 @@ function buildChartConfig(id: string, stats: any[]) { } /** - * 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) { +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, @@ -303,7 +326,7 @@ function saveStats({ metrics, memusage }: any, data: any[]) { // language=HTML let html = ` - + IMQ Benchmark results @@ -313,13 +336,13 @@ 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.
@@ -331,9 +354,9 @@ function saveStats({ metrics, memusage }: any, data: any[]) {
  • 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()}
  • +
  • RAM: ${Math.ceil(totalmem() / Math.pow(1024, 3))}GB
  • +
  • OS Architecture: ${arch()}
  • +
  • OS Platform: ${platform()}
  • Node Version: ${process.versions.node}
@@ -353,7 +376,7 @@ function saveStats({ metrics, memusage }: any, data: any[]) { fmt, 'srcBytesLen', )} bytes -
  • Average time of all messages delivery is: ${fmt.format( +
  • The average time of all messages deliveries is: ${fmt.format( Number( ( data.reduce((prev, next) => prev + next.time, 0) / @@ -393,14 +416,14 @@ 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' }); + writeFileSync(htmlFile, html, { encoding: 'utf8' }); - console.log('Benchmark stats saved!'); - console.log(`Opening file://${htmlFile}`); + console.info('Benchmark stats saved!'); + console.info(`Opening file://${htmlFile}`); import('open').then(open => open.default(`file://${htmlFile}`, { wait: false }), @@ -410,7 +433,7 @@ function saveStats({ metrics, memusage }: any, data: any[]) { // main program: -if (cluster.isMaster) { +if (cluster.isPrimary) { setAffinity(1); const statsWorker = cluster.fork(); @@ -478,7 +501,7 @@ if (cluster.isMaster) { if ( core && - os.platform() === 'linux' && + platform() === 'linux' && /redis-server/.test(redisProcess) && !/grep/.test(redisProcess) ) { @@ -490,11 +513,11 @@ if (cluster.isMaster) { metricsInterval = setInterval(() => { metrics.push( - CPU_NAMES.map((name: string, i: number) => cpuAvg(i + 1)), + 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') { @@ -502,7 +525,7 @@ if (cluster.isMaster) { clearInterval(metricsInterval); } metricsInterval = null; - console.log('Finalizing...'); + console.info('Finalizing...'); (process).send( 'metrics:' + JSON.stringify({ diff --git a/benchmark/redis-test.ts b/benchmark/redis-test.ts index 3811b5f..6819525 100644 --- a/benchmark/redis-test.ts +++ b/benchmark/redis-test.ts @@ -22,7 +22,7 @@ * to get commercial licensing options. */ import { randomUUID as uuid } from 'crypto'; -import IMQ, { IMQOptions, IJson, pack, JsonObject, AnyJson } from '../index'; +import IMQ, { IMQOptions, pack, JsonObject, AnyJson } from '../index'; /** * Sample message used within tests @@ -89,7 +89,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; } @@ -101,7 +101,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,7 +111,7 @@ export async function run( useGzip: boolean = false, safeDelivery: boolean = false, jsonExample: AnyJson = JSON_EXAMPLE, -) { +): Promise { const bytesLen = bytes( (useGzip ? pack : JSON.stringify)(jsonExample), useGzip, @@ -137,13 +137,13 @@ export async function run( mq.on('message', () => count++); if (msgDelay) { - console.log( + console.info( 'Sending %s messages, using %s delay please, wait...', fmt.format(steps), fmt.format(msgDelay), ); } else { - console.log( + console.info( 'Sending %s messages, please, wait...', fmt.format(steps), ); @@ -160,16 +160,16 @@ export async function run( const time = Date.now() - start; const ratio = count / (time / 1000); - console.log( + console.info( '%s is sent/received in %s ±10 ms', fmt.format(count), fmt.format(time), ); - console.log( + console.info( 'Round-trip ratio: %s messages/sec', fmt.format(ratio), ); - console.log( + console.info( 'Message payload is: %s bytes', fmt.format(bytesLen), ); diff --git a/src/ClusterManager.ts b/src/ClusterManager.ts index ba07bcb..ebf6a32 100644 --- a/src/ClusterManager.ts +++ b/src/ClusterManager.ts @@ -40,9 +40,11 @@ export abstract class ClusterManager { protected constructor() {} public init(cluster: ICluster): InitializedCluster { - const initializedCluster = Object.assign(cluster, { + // build a new object rather than mutating the caller's cluster + const initializedCluster: InitializedCluster = { + ...cluster, id: randomUUID(), - }) as InitializedCluster; + }; this.clusters.push(initializedCluster); diff --git a/src/ClusteredRedisQueue.ts b/src/ClusteredRedisQueue.ts index 8c7b775..9979f22 100644 --- a/src/ClusteredRedisQueue.ts +++ b/src/ClusteredRedisQueue.ts @@ -38,6 +38,12 @@ import { } from '.'; import { InitializedCluster } from './ClusterManager'; +/** + * Default time (ms) send() waits for the first cluster server to become + * available before rejecting, when the cluster is still empty. + */ +const SEND_INIT_TIMEOUT = 30000; + interface ClusterServer extends IMessageQueueConnection { imq?: RedisQueue; } @@ -46,13 +52,13 @@ 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 @@ -101,6 +107,11 @@ export class ClusteredRedisQueue */ private currentQueue: number = 0; + /** + * 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 * @@ -137,12 +148,12 @@ export class ClusteredRedisQueue * @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(); @@ -212,15 +223,15 @@ export class ClusteredRedisQueue } /** - * 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( @@ -230,25 +241,86 @@ export class ClusteredRedisQueue 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. + * + * @access private + * @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. + * + * @access private + * @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); + }); } /** @@ -270,8 +342,8 @@ export class ClusteredRedisQueue 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); } } @@ -316,7 +388,7 @@ export class ClusteredRedisQueue * @access private * @param {string} action * @param {string} message - * @return {Promise} + * @returns {Promise} */ private async batch( action: 'start' | 'stop' | 'destroy' | 'clear', @@ -344,9 +416,10 @@ export class ClusteredRedisQueue * (method chosen by name), so a single contained cast bridges the dynamic * call while the public method signatures below stay fully typed. * - * @param {K} method - * @param {any[]} args - * @return {unknown[]} + * @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, @@ -462,7 +535,7 @@ export class ClusteredRedisQueue public async subscribe( channel: string, - handler: (data: JsonObject) => any, + handler: (data: JsonObject) => void, ): Promise { this.state.subscription = { channel, handler }; @@ -520,7 +593,11 @@ export class ClusteredRedisQueue this.imqs = this.imqs.filter( 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; @@ -552,6 +629,8 @@ export class ClusteredRedisQueue 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', { @@ -576,7 +655,6 @@ export class ClusteredRedisQueue } private async initializeQueue(imq: RedisQueue): Promise { - copyEventEmitter(this.templateEmitter, imq); this.verbose( `Initializing queue with state: ${JSON.stringify(this.state)}`, ); diff --git a/src/IMessageQueue.ts b/src/IMessageQueue.ts index a3e72bb..d17c044 100644 --- a/src/IMessageQueue.ts +++ b/src/IMessageQueue.ts @@ -51,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 */ @@ -66,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} */ @@ -201,8 +193,7 @@ 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} */ @@ -223,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} */ @@ -283,10 +271,9 @@ export interface IMQOptions extends Partial { clusterManagers?: ClusterManager[]; /** - * Enables/disables process signal handling (SIGTERM, SIGINT, SIGABRT) - * by the queue. When enabled, the queue frees its watcher lock and - * exits the process on those signals. Disable when the host - * application manages its own shutdown sequence. + * 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} @@ -294,9 +281,9 @@ export interface IMQOptions extends Partial { handleSignals?: boolean; /** - * When enabled, send() resolves only after the message write is - * confirmed by redis (and rejects on write failures). By default - * writes are fire-and-forget for maximum throughput and failures are + * When enabled, send() resolves only after the message writing is + * confirmed by redis (and rejects on writing failures). By default, + * writes are fire-and-forget for maximum throughput, and failures are * reported through the optional errorHandler argument only. * * @default false @@ -324,7 +311,7 @@ export interface IMQOptions extends Partial { } export interface EventMap { - message: [data: any, id: string, from: string]; + message: [data: JsonObject, id: string, from: string]; error: [error: Error, eventName: string]; } @@ -343,78 +330,75 @@ export type IMessageQueueConstructor = new ( * import { randomUUID } from '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 = randomUUID(); - * // ... implementation goes here - * return messageId; - * } - * public async destroy(): Promise { - * // ... implementation goes here - * } - * public async clear(): Promise { - * // ... implementation goes here - * return this; - * } + 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 to handle queue messages). - * Supposed to be an async function. + * Stops the queue from handling messages. * * @returns {Promise} */ stop(): Promise; /** - * Sends a message to the 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 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. - * @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, @@ -428,46 +412,43 @@ export interface IMessageQueue extends EventEmitter { * 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 the current queue channel * - * If toName specified will publish to pubsub with a different name. This - * can be used to implement broadcasting some messages to other subscribers - * on other pubsub channels. Different names 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 the 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 2b2f368..31ec56a 100644 --- a/src/RedisQueue.ts +++ b/src/RedisQueue.ts @@ -63,7 +63,7 @@ const SCAN_COUNT = '1000'; * ioredis retry strategy that disables the built-in reconnection — this * queue performs its own capped-backoff reconnection instead. * - * @return {null} + * @returns {null} */ function noRetryStrategy(): null { return null; @@ -73,7 +73,7 @@ function noRetryStrategy(): null { * Resolves after the given number of milliseconds. * * @param {number} ms - * @return {Promise} + * @returns {Promise} */ function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); @@ -204,9 +204,9 @@ export class RedisQueue * Subscription handlers registered through subscribe(). Kept to be * able to restore the subscription after a connection replacement. * - * @type {Array<(data: JsonObject) => any>} + * @type {Array<(data: JsonObject) => void>} */ - private subscriptionHandlers: Array<(data: JsonObject) => any> = []; + private subscriptionHandlers: Array<(data: JsonObject) => void> = []; /** * Will store check interval reference @@ -276,20 +276,20 @@ export class RedisQueue }; /** - * 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 @@ -338,12 +338,12 @@ export class RedisQueue * 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 { if (!channel) { throw new TypeError( @@ -377,11 +377,11 @@ export class RedisQueue * * @access private * @param {IRedisClient} chan - * @param {(data: JsonObject) => any} handler + * @param {(data: JsonObject) => void} handler */ private attachSubscriptionHandler( chan: IRedisClient, - handler: (data: JsonObject) => any, + handler: (data: JsonObject) => void, ): void { const fcn = `${this.options.prefix}:${this.subscriptionName}`; @@ -399,12 +399,12 @@ export class RedisQueue } /** - * Restores subscription state on a freshly created subscription - * connection. Used after a connection replacement on reconnect, + * 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. * * @access private - * @return {Promise} + * @returns {Promise} */ private async restoreSubscription(): Promise { const chan = this.subscription; @@ -431,7 +431,7 @@ export class RedisQueue /** * Closes subscription channel * - * @return {Promise} + * @returns {Promise} */ public async unsubscribe(): Promise { if (this.subscription) { @@ -566,7 +566,7 @@ export class RedisQueue * the process, forcing exit after IMQ_SHUTDOWN_TIMEOUT at the latest. * * @access private - * @return {Promise} + * @returns {Promise} */ private static async freeAndExit(): Promise { let exitCode = 0; @@ -592,7 +592,7 @@ export class RedisQueue /** * 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 delayed messages when + * Also serves as a polling fallback moving due to delayed messages when * keyspace notifications are unavailable. * * @access private @@ -611,11 +611,11 @@ export class RedisQueue /** * A single watcher-existence check tick: re-elects a watcher owner when - * none exists and moves due delayed messages as a keyspace-notification + * none exists and moves due to delayed messages as a keyspace-notification * fallback. Errors are contained so the interval never crashes. * * @access private - * @return {Promise} + * @returns {Promise} */ private async runWatcherCheck(): Promise { if (this.watcherCheckBusy || this.destroyed || !this.writer) { @@ -730,7 +730,7 @@ export class RedisQueue `${key}:delayed`, Date.now() + delay, packet, - (err: any) => { + (err?: Error | null) => { if (err) { onWriteError(err, 'ZADD'); @@ -744,7 +744,7 @@ export class RedisQueue 'PX', delay, 'NX', - (err: any) => { + (err?: Error | null) => { if (err) { onWriteError(err, 'SET'); @@ -752,22 +752,24 @@ export class RedisQueue } }, ) - .catch((err: any) => onWriteError(err, 'SET')); + .catch((err: unknown) => onWriteError(err, 'SET')); }, ); } else { - const result: any = this.writer.lpush(key, packet, (err: any) => { - if (err) { - onWriteError(err, 'LPUSH'); - - return; - } - }); + const result = this.writer.lpush( + key, + packet, + (err?: Error | null) => { + if (err) { + onWriteError(err, 'LPUSH'); + } + }, + ); // guard against unhandled rejections from promise-returning // client implementations in fire-and-forget mode if (result && typeof result.catch === 'function') { - result.catch((err: any) => onWriteError(err, 'LPUSH')); + result.catch((err: unknown) => onWriteError(err, 'LPUSH')); } } @@ -800,7 +802,7 @@ export class RedisQueue /** * 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 + * 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 @@ -885,7 +887,7 @@ export class RedisQueue /** * 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; @@ -894,12 +896,25 @@ export class RedisQueue /** * 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; } + /** + * 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 * @@ -942,7 +957,7 @@ export class RedisQueue * * @access private * @param {RedisConnectionChannel} channel - * @return {IRedisClient | undefined} + * @returns {IRedisClient | undefined} */ private connectionOf( channel: RedisConnectionChannel, @@ -1209,12 +1224,12 @@ export class RedisQueue /** * Performs a single reconnection attempt for the given channel, - * rescheduling itself on failure. Errors are handled internally so the + * rescheduling itself on failure. Errors are handled internally, so the * scheduled timer never produces an unhandled rejection. * * @access private * @param {RedisConnectionChannel} channel - * @return {Promise} + * @returns {Promise} */ private async reconnectNow(channel: RedisConnectionChannel): Promise { if (this.destroyed) { @@ -1266,7 +1281,7 @@ export class RedisQueue * @param {string} contextName * @param {string} prefix * @param {RedisConnectionChannel} name - * @return {string} + * @returns {string} */ private getChannelName( contextName: string, @@ -1283,7 +1298,7 @@ export class RedisQueue * * @access private * @param {RedisConnectionChannel} channel - * @return {(err: Error) => void} + * @returns {(err: Error) => void} */ private onErrorHandler( channel: RedisConnectionChannel, @@ -1317,7 +1332,7 @@ export class RedisQueue * * @access private * @param {RedisConnectionChannel} channel - * @return {() => void} + * @returns {() => void} */ private onCloseHandler(channel: RedisConnectionChannel): () => void { this.verbose(`Redis ${channel} is closing...`); @@ -1343,11 +1358,10 @@ export class RedisQueue * 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; if (!queue || queue !== this.key) { @@ -1355,9 +1369,8 @@ export class RedisQueue } 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) { this.emitError( @@ -1382,11 +1395,9 @@ export class RedisQueue } const rx = new RegExp( - `\\bname=${escapeRegExp( - 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; @@ -1418,7 +1429,7 @@ export class RedisQueue ); } catch (err) { // the script may not be cached on the redis host (fresh - // host, restart, non-owner instance) - fall back to EVAL + // 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( @@ -1445,7 +1456,7 @@ export class RedisQueue * Watch routine * * @access private - * @return {Promise} + * @returns {Promise} */ private async processWatch(): Promise { const now = Date.now(); @@ -1453,7 +1464,7 @@ export class RedisQueue while (true) { try { - const data = await this.writer.scan( + const [next, keys] = await this.writer.scan( cursor, 'MATCH', `${this.options.prefix}:*:worker:*`, @@ -1461,9 +1472,7 @@ export class RedisQueue SCAN_COUNT, ); - cursor = data.shift() as string; - - const keys = (data.shift() as string[]) || []; + cursor = next; await this.processKeys(keys, now); @@ -1489,7 +1498,7 @@ export class RedisQueue * @access private * @param {string[]} keys * @param {number} now - * @return {Promise} + * @returns {Promise} */ private async processKeys(keys: string[], now: number): Promise { if (!keys.length) { @@ -1527,7 +1536,7 @@ export class RedisQueue * * @access private * @param {...any[]} args - * @return {Promise} + * @returns {Promise} */ private async onWatchMessage(...args: any[]): Promise { try { @@ -1608,7 +1617,7 @@ export class RedisQueue * workers (when safe delivery is on) and prunes orphaned keys. * * @access private - * @return {Promise} + * @returns {Promise} */ private async runSafeCheck(): Promise { if (!this.writer) { @@ -1665,8 +1674,8 @@ export class RedisQueue a.indexOf(name) === i, ); // clients seen connected during the previous sweep get one - // sweep of grace: a client that is merely reconnecting (the - // backoff can reach tens of seconds) must not have its keys + // 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( @@ -1686,7 +1695,7 @@ export class RedisQueue ); while (true) { - const data = await this.writer.scan( + const [next, keys] = await this.writer.scan( cursor, 'MATCH', `${this.options.prefix}:${ @@ -1696,9 +1705,7 @@ export class RedisQueue SCAN_COUNT, ); - cursor = data.shift() as string; - - const keys = (data.shift() as string[]) || []; + cursor = next; keysToRemove.push( ...keys.filter( @@ -1966,7 +1973,7 @@ export class RedisQueue * ownership. Used to resolve a possible watcher deadlock. * * @access private - * @return {Promise} + * @returns {Promise} */ private async resolveWatchLock(): Promise { const noWatcher = !(await this.watcherCount()); diff --git a/src/UDPClusterManager.ts b/src/UDPClusterManager.ts index 8379e5a..c17aaab 100644 --- a/src/UDPClusterManager.ts +++ b/src/UDPClusterManager.ts @@ -22,10 +22,22 @@ * to get commercial licensing options. */ import { ClusterManager, ICluster } from './ClusterManager'; +import { IServerInput } from './IMessageQueue'; import { Worker } from 'worker_threads'; -import * as path from 'path'; +import { join } from 'path'; -process.setMaxListeners(10000); +/** Shape of a message posted from the UDP worker thread */ +interface WorkerMessage { + type?: string; + server?: unknown; +} + +/** Minimal socket surface destroySocket() interacts with */ +interface DisposableSocket { + removeAllListeners?: () => void; + close?: (callback?: () => void) => void; + unref?: () => void; +} export interface UDPClusterManagerOptions { /** @@ -44,7 +56,7 @@ export interface UDPClusterManagerOptions { address: string; /** - * Message queue limited broadcast address + * Message-queue-limited broadcast address * * @default "255.255.255.255" * @type {string} @@ -85,7 +97,15 @@ export const DEFAULT_UDP_CLUSTER_MANAGER_OPTIONS: UDPClusterManagerOptions = { export class UDPClusterManager extends ClusterManager { private static workers: Record = {}; - public static sockets: Record = {}; + + /** Number of manager instances sharing each worker (by address:port) */ + private static workerRefs: Record = {}; + + public static sockets: Record = {}; + + /** True once process-level signal handlers have been registered */ + private static signalsBound: boolean = false; + private readonly options: UDPClusterManagerOptions; private workerKey!: string; private worker!: Worker; @@ -100,6 +120,22 @@ export class UDPClusterManager extends ClusterManager { this.startWorkerListener(); + UDPClusterManager.bindSignals(); + } + + /** + * Registers process-level shutdown handlers exactly once per process, + * so multiple manager instances do not accumulate duplicate listeners. + * + * @access private + */ + private static bindSignals(): void { + if (UDPClusterManager.signalsBound) { + return; + } + + UDPClusterManager.signalsBound = true; + process.on('SIGTERM', UDPClusterManager.free); process.on('SIGINT', UDPClusterManager.free); process.on('SIGABRT', UDPClusterManager.free); @@ -120,6 +156,8 @@ export class UDPClusterManager extends ClusterManager { private startWorkerListener(): void { this.workerKey = `${this.options.address}:${this.options.port}`; + UDPClusterManager.workerRefs[this.workerKey] = + (UDPClusterManager.workerRefs[this.workerKey] || 0) + 1; if (UDPClusterManager.workers[this.workerKey]) { this.worker = UDPClusterManager.workers[this.workerKey]; @@ -127,53 +165,71 @@ export class UDPClusterManager extends ClusterManager { return; } - this.worker = new Worker(path.join(__dirname, './UDPWorker.js'), { + this.worker = new Worker(join(__dirname, './UDPWorker.js'), { workerData: this.options, }); - this.worker.on('message', message => { - const [className, method] = (message.type ?? '').split(':'); - - if (className !== 'cluster') { - return; - } - - 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 */ - } - } + this.worker.on('message', (message: WorkerMessage) => { + void this.handleWorkerMessage(message); + }); + + UDPClusterManager.workers[this.workerKey] = this.worker; + } + + /** + * Applies a worker cluster message (add/remove) to every registered + * cluster. Errors from cluster callbacks are contained here, so the + * worker message listener can never raise an unhandled rejection. + * + * @access private + * @param {WorkerMessage} message + * @returns {Promise} + */ + private async handleWorkerMessage(message: WorkerMessage): Promise { + const [className, method] = String(message.type ?? '').split(':'); - const clusterMethod = (cluster as any)[ - method as keyof ICluster - ]; + if (className !== 'cluster') { + return; + } + + const action = method as keyof ICluster; - if (!clusterMethod) { + try { + await this.anyCluster(cluster => { + const server = message.server as IServerInput; + + if (action === 'add' && cluster.find(server)) { return; } - clusterMethod(message.server); - }); - }); + const handler = cluster[action] as + | ((server: IServerInput) => unknown) + | undefined; - UDPClusterManager.workers[this.workerKey] = this.worker; + handler?.(server); + }); + } catch { + // a failing cluster callback must not crash the listener + } } public async destroy(): Promise { + const refs = UDPClusterManager.workerRefs[this.workerKey] ?? 0; + + if (refs > 1) { + // the worker is still shared with other manager instances on + // the same address:port — just release this reference + UDPClusterManager.workerRefs[this.workerKey] = refs - 1; + + return; + } + + delete UDPClusterManager.workerRefs[this.workerKey]; await UDPClusterManager.destroyWorker(this.workerKey, this.worker); } public static async destroySocket( key: string, - socket?: any, + socket?: DisposableSocket, ): Promise { if (!socket) { return; @@ -183,12 +239,14 @@ export class UDPClusterManager extends ClusterManager { socket.removeAllListeners(); } - if (typeof socket.close !== 'function') { + const close = socket.close; + + if (typeof close !== 'function') { return; } await new Promise(resolve => { - socket.close(() => { + close.call(socket, () => { if (typeof socket.unref === 'function') { socket.unref(); } diff --git a/src/UDPWorker.ts b/src/UDPWorker.ts index da40993..3433c12 100644 --- a/src/UDPWorker.ts +++ b/src/UDPWorker.ts @@ -125,7 +125,7 @@ class UDPWorker { this.servers.set(key, stamp); - const t: any = setTimeout( + const timer: NodeJS.Timeout = setTimeout( () => setImmediate(() => { if (this.servers.get(key) === stamp) { @@ -134,14 +134,9 @@ class UDPWorker { }), effectiveTimeout, ); - // Avoid keeping the event loop alive due to pending timers - try { - if (t && typeof t.unref === 'function') { - t.unref(); - } - } catch { - /* ignore */ - } + + // a pending liveness timer must not keep the worker alive on its own + timer.unref(); } private processMessage(message: Message): void { @@ -218,13 +213,15 @@ class UDPWorker { this.messagePort.postMessage({ type: 'stopped' }); } - private cleanup(): void { + // arrow field so the bound reference can be passed to process signal + // handlers without losing `this` + private readonly cleanup = (): void => { this.servers.clear(); if (this.socket) { this.socket.removeAllListeners(); } - } + }; } if (!isMainThread && parentPort) { diff --git a/src/helpers/buildOptions.ts b/src/helpers/buildOptions.ts index 411f089..3b78a8d 100644 --- a/src/helpers/buildOptions.ts +++ b/src/helpers/buildOptions.ts @@ -28,7 +28,7 @@ * @template T * @param {T} defaultOptions * @param {Partial} givenOptions - * @return {T} + * @returns {T} */ export function buildOptions( defaultOptions: T, diff --git a/src/helpers/envInt.ts b/src/helpers/envInt.ts index c291a71..4ba82b1 100644 --- a/src/helpers/envInt.ts +++ b/src/helpers/envInt.ts @@ -27,7 +27,7 @@ * * @param {string} name * @param {number} defaultValue - * @return {number} + * @returns {number} */ export function envInt(name: string, defaultValue: number): number { const value = Number(process.env[name] || defaultValue); diff --git a/src/helpers/escapeRegExp.ts b/src/helpers/escapeRegExp.ts index 99fda09..05f5bf1 100644 --- a/src/helpers/escapeRegExp.ts +++ b/src/helpers/escapeRegExp.ts @@ -25,7 +25,7 @@ * Escapes regular expression special characters in a given string * * @param {string} str - * @return {string} + * @returns {string} */ export function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); diff --git a/src/helpers/pack.ts b/src/helpers/pack.ts index c3a0f08..3e2b532 100644 --- a/src/helpers/pack.ts +++ b/src/helpers/pack.ts @@ -24,11 +24,11 @@ import { gzipSync } from 'zlib'; /** - * Compress given data and returns binary string + * Compresses the given data and returns a binary string * - * @param {any} data - * @returns {string} + * @param {unknown} data - data to compress + * @returns {string} - compressed data as a binary string */ -export function pack(data: any): string { +export function pack(data: unknown): string { return gzipSync(JSON.stringify(data)).toString('binary'); } diff --git a/src/helpers/unpack.ts b/src/helpers/unpack.ts index 7136370..02f15b7 100644 --- a/src/helpers/unpack.ts +++ b/src/helpers/unpack.ts @@ -24,11 +24,13 @@ import { gunzipSync } from 'zlib'; /** - * Decompress binary string and returns plain data + * Decompresses a binary string and returns the decompressed data * - * @param {string} data - * @returns {any} + * @param {string} data - compressed data string + * @returns {unknown} - decompressed data */ -export function unpack(data: string): any { - return JSON.parse(gunzipSync(Buffer.from(data, 'binary')).toString()); +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 4a28626..8058e24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,9 +20,9 @@ * to get commercial licensing options. */ export * from './helpers'; -export * from './IMQMode'; export * from './profile'; export * from './redis'; +export * from './IMQMode'; export * from './IMessageQueue'; export * from './RedisQueue'; export * from './ClusteredRedisQueue'; diff --git a/src/profile.ts b/src/profile.ts index 6f3a284..b200602 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -40,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 the log level is set to the proper value or returns the 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: @@ -73,27 +73,25 @@ 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: 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: 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} */ @@ -102,11 +100,11 @@ export const IMQ_LOG_TIME_FORMAT: AllowedTimeFormat = 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; /** @@ -116,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 */ @@ -136,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, @@ -185,17 +176,17 @@ export function logDebugInfo({ if (debugArgs) { let argStr: string = ''; - const cache: any[] = []; + const cache: unknown[] = []; try { argStr = JSON.stringify( args, - (key: string, value: any) => { + (_key: string, value: unknown) => { if (typeof value === 'object' && value !== null) { if (~cache.indexOf(value)) { try { return JSON.parse(JSON.stringify(value)); - } catch (error) { + } catch { return; } } @@ -208,7 +199,7 @@ export function logDebugInfo({ 2, ); } catch (err) { - logger.error(err); + logger?.error(err); } if (log) { @@ -217,6 +208,56 @@ 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. + * + * @param {unknown} value + * @returns {value is PromiseLike} + */ +function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ); +} + /** * Implements '@profile' decorator. * @@ -226,25 +267,25 @@ export function logDebugInfo({ * * class MyClass { * - * @profile(true) // forced profiling - * public myMethod() { - * // ... - * } + @profile(true) // forced profiling + public myMethod() { + // ... + } * - * @profile() // profiling happened only depending on env DEBUG flag - * private innerMethod() { - * // ... - * } + @profile() // profiling happened 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( +export function profile( options?: ProfileDecoratorOptions, ): ( original: (this: This, ...args: Args) => Return, @@ -281,12 +322,8 @@ export function profile( return original.apply(this, args); } - const self = this as any; - const className = self - ? typeof self === 'function' - ? self.name // static - : self.constructor && self.constructor.name // dynamic - : ''; + const className = resolveClassName(this); + const logger = resolveLogger(this); const start = process.hrtime.bigint(); const result = original.apply(this, args); const debugOptions: DebugInfoOptions = { @@ -295,22 +332,16 @@ export function profile( debugArgs, debugTime, logLevel: logLevel ? verifyLogLevel(logLevel) : IMQ_LOG_LEVEL, - logger: self && self.logger, + logger, methodName, start, }; - if (result && typeof (result as any).then === 'function') { - // async call detected - (result as any) - .then((res: any) => { - logDebugInfo(debugOptions); - - return res; - }) - .catch(() => { - logDebugInfo(debugOptions); - }); + if (isThenable(result)) { + // async call detected — log once it settles either way + const logAfter = (): void => logDebugInfo(debugOptions); + + result.then(logAfter, logAfter); return result; } diff --git a/src/redis.ts b/src/redis.ts index 708cf93..89db614 100644 --- a/src/redis.ts +++ b/src/redis.ts @@ -24,7 +24,7 @@ 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/test/mocks/os.ts b/test/mocks/os.ts index d62155c..a051c23 100644 --- a/test/mocks/os.ts +++ b/test/mocks/os.ts @@ -20,7 +20,7 @@ * to get commercial licensing options. */ import mock = require('mock-require'); -import * as os from 'node:os'; +import os from 'node:os'; export const networkInterfaces = () => { return { diff --git a/test/mocks/redis.ts b/test/mocks/redis.ts index 738f68e..6fa73e1 100644 --- a/test/mocks/redis.ts +++ b/test/mocks/redis.ts @@ -23,10 +23,10 @@ */ import mock from 'mock-require'; import { EventEmitter } from 'events'; -import * as crypto from 'crypto'; +import { createHash, Hash } from 'crypto'; function sha1(str: string) { - let sha: crypto.Hash = crypto.createHash('sha1'); + let sha: Hash = createHash('sha1'); sha.update(str); return sha.digest('hex'); } diff --git a/test/unit/ClusterManager.spec.ts b/test/unit/ClusterManager.spec.ts index b86ffa7..a2808e3 100644 --- a/test/unit/ClusterManager.spec.ts +++ b/test/unit/ClusterManager.spec.ts @@ -6,7 +6,7 @@ */ import '../mocks'; import { describe, it, afterEach, mock } from 'node:test'; -import * as assert from 'node:assert/strict'; +import assert from 'node:assert/strict'; import { ClusterManager, InitializedCluster } from '../../src/ClusterManager'; class TestClusterManager extends ClusterManager { diff --git a/test/unit/ClusteredRedisQueue.spec.ts b/test/unit/ClusteredRedisQueue.spec.ts index 0842680..2635680 100644 --- a/test/unit/ClusteredRedisQueue.spec.ts +++ b/test/unit/ClusteredRedisQueue.spec.ts @@ -24,7 +24,7 @@ */ import { logger } from '../mocks'; import { describe, it, afterEach, mock, Mock } from 'node:test'; -import * as assert from 'node:assert/strict'; +import assert from 'node:assert/strict'; import { ClusteredRedisQueue, RedisQueue } from '../../src'; import { ClusterManager } from '../../src/ClusterManager'; diff --git a/test/unit/IMessageQueue.spec.ts b/test/unit/IMessageQueue.spec.ts index 9dc7cfd..10bb13b 100644 --- a/test/unit/IMessageQueue.spec.ts +++ b/test/unit/IMessageQueue.spec.ts @@ -21,7 +21,7 @@ */ import '../mocks'; import { describe, it } from 'node:test'; -import * as assert from 'node:assert/strict'; +import assert from 'node:assert/strict'; import { EventEmitter as IMQEventEmitter } from '../../src'; import { EventEmitter as NodeEventEmitter } from 'events'; diff --git a/test/unit/RedisQueue.spec.ts b/test/unit/RedisQueue.spec.ts index 7e53100..c2974a1 100644 --- a/test/unit/RedisQueue.spec.ts +++ b/test/unit/RedisQueue.spec.ts @@ -27,7 +27,7 @@ */ import '../mocks'; import { describe, it, beforeEach, afterEach, mock, Mock } from 'node:test'; -import * as assert from 'node:assert/strict'; +import assert from 'node:assert/strict'; import { RedisQueue, IMQMode, sha1, escapeRegExp } from '../../src'; import { logger, RedisClientMock } from '../mocks'; import { randomUUID as uuid } from 'crypto'; diff --git a/test/unit/UDPClusterManager.spec.ts b/test/unit/UDPClusterManager.spec.ts index 77e0bfd..ea3d9e4 100644 --- a/test/unit/UDPClusterManager.spec.ts +++ b/test/unit/UDPClusterManager.spec.ts @@ -26,7 +26,7 @@ */ import '../mocks'; import { describe, it, afterEach, mock } from 'node:test'; -import * as assert from 'node:assert/strict'; +import assert from 'node:assert/strict'; import { UDPClusterManager } from '../../src'; const testMessageUp = { diff --git a/test/unit/helpers/copyEventEmitter.spec.ts b/test/unit/helpers/copyEventEmitter.spec.ts index f4caee3..cc6d23f 100644 --- a/test/unit/helpers/copyEventEmitter.spec.ts +++ b/test/unit/helpers/copyEventEmitter.spec.ts @@ -21,7 +21,7 @@ */ import '../../mocks'; import { describe, it } from 'node:test'; -import * as assert from 'node:assert/strict'; +import assert from 'node:assert/strict'; import { EventEmitter } from 'events'; import { copyEventEmitter } from '../../../src'; diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index be59d42..8123ae2 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -23,7 +23,7 @@ */ import '../mocks'; import { describe, it } from 'node:test'; -import * as assert from 'node:assert/strict'; +import assert from 'node:assert/strict'; import IMQ, { RedisQueue, ClusteredRedisQueue } from '../..'; describe('IMQ', () => { diff --git a/test/unit/profile.spec.ts b/test/unit/profile.spec.ts index 0e2408a..e546bec 100644 --- a/test/unit/profile.spec.ts +++ b/test/unit/profile.spec.ts @@ -24,7 +24,7 @@ */ import '../mocks'; import { describe, it, beforeEach, afterEach, mock, Mock } from 'node:test'; -import * as assert from 'node:assert/strict'; +import assert from 'node:assert/strict'; import mockRequire from 'mock-require'; import { profile, From 9dd70691fb26bb3268cf07562b62d4800d1058dd Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Mon, 6 Jul 2026 23:34:18 +0200 Subject: [PATCH 06/20] fix: formatting --- benchmark/index.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/benchmark/index.ts b/benchmark/index.ts index c452629..f0dc8f8 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -177,7 +177,7 @@ for (let i = 0; i < maxChildren; i++) { * @param {number} i * @returns {{idle: number, total: number}} */ -function cpuAvg(i: number): { idle: number; total: number; } { +function cpuAvg(i: number): { idle: number; total: number } { const cpuList = cpus(); const cpu: any = cpuList[i]; let totalIdle = 0; @@ -247,10 +247,10 @@ interface ChartConfig { centered: boolean; fit: boolean; culling: { max: number }; - outer: boolean - } + outer: boolean; + }; }; - y: { max: number; tick: { outer: boolean } } + y: { max: number; tick: { outer: boolean } }; }; zoom: { enabled: boolean }; } @@ -276,7 +276,7 @@ function buildChartConfig(id: string, stats: any[]): ChartConfig { .slice(1) .map( (_: any, i: number) => - (((i * 100) / 1000).toFixed(1) + 's'), + ((i * 100) / 1000).toFixed(1) + 's', ), tick: { centered: true, @@ -512,9 +512,7 @@ if (cluster.isPrimary) { } metricsInterval = setInterval(() => { - metrics.push( - CPU_NAMES.map((_, i: number) => cpuAvg(i + 1)), - ); + metrics.push(CPU_NAMES.map((_, i: number) => cpuAvg(i + 1))); memusage.push({ total: totalmem(), free: freemem(), From 5fc2a53c1f0135f16ee29a8b26933c68b2f44b3b Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Mon, 6 Jul 2026 23:36:30 +0200 Subject: [PATCH 07/20] fix: test coverage report --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 558b4f4..b26c19b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "format:check": "oxfmt --check \"index.ts\" \"src/**/*.ts\" \"test/**/*.ts\" \"benchmark/**/*.ts\"", "test": "tsc && node --test --test-timeout=15000 \"test/**/*.spec.js\"", "test-coverage": "tsc && node --test --experimental-test-coverage --test-timeout=15000 \"test/**/*.spec.js\"", - "test-lcov": "tsc && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-timeout=15000 \"test/**/*.spec.js\"", + "test-lcov": "tsc && mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-timeout=15000 \"test/**/*.spec.js\"", "test-dev": "npm run test && npm run clean-js && npm run clean-typedefs && npm run clean-maps", "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", From 0c8e73e6e6ae78ae8f97229a9532203f2183484f Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Mon, 6 Jul 2026 23:40:54 +0200 Subject: [PATCH 08/20] fix: tests --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index b26c19b..562a02f 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,9 @@ "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": "tsc && node --test --test-timeout=15000 \"test/**/*.spec.js\"", - "test-coverage": "tsc && node --test --experimental-test-coverage --test-timeout=15000 \"test/**/*.spec.js\"", - "test-lcov": "tsc && mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-timeout=15000 \"test/**/*.spec.js\"", + "test": "tsc && node --test --test-timeout=15000 $(find test -name '*.spec.js')", + "test-coverage": "tsc && node --test --experimental-test-coverage --test-timeout=15000 $(find test -name '*.spec.js')", + "test-lcov": "tsc && mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-timeout=15000 $(find test -name '*.spec.js')", "test-dev": "npm run test && npm run clean-js && npm run clean-typedefs && npm run clean-maps", "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", From 30f99f4e4e21df7f31fb88443b0e4beeb1fddd49 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Mon, 6 Jul 2026 23:47:25 +0200 Subject: [PATCH 09/20] fix: github actions workflow --- .github/workflows/build.yml | 44 +++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 06d757b..db3ff13 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,25 +2,30 @@ name: Build on: push: + branches: [master] + tags: ['*'] pull_request: -jobs: - build: - runs-on: ubuntu-latest +permissions: + contents: read - strategy: - fail-fast: false - matrix: - node-version: [20.x, 22.x, 24.x] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + quality: + name: Lint & format + runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: 24.x cache: npm - name: Install dependencies @@ -32,6 +37,27 @@ jobs: - 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: [22.x, 24.x] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + - name: Run tests run: npm run test-lcov From 0edb13e0394c8702bc4ce3a64a82903488b562f7 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Tue, 7 Jul 2026 00:07:28 +0200 Subject: [PATCH 10/20] fix: improve tests coverage --- test/mocks/redis.ts | 6 + test/unit/ClusteredRedisQueue.spec.ts | 83 +++++++++++ test/unit/RedisQueue.spec.ts | 189 ++++++++++++++++++++++++++ 3 files changed, 278 insertions(+) diff --git a/test/mocks/redis.ts b/test/mocks/redis.ts index 6fa73e1..849715f 100644 --- a/test/mocks/redis.ts +++ b/test/mocks/redis.ts @@ -208,6 +208,12 @@ export class RedisClientMock extends EventEmitter { } } + 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, diff --git a/test/unit/ClusteredRedisQueue.spec.ts b/test/unit/ClusteredRedisQueue.spec.ts index 2635680..63dcc0c 100644 --- a/test/unit/ClusteredRedisQueue.spec.ts +++ b/test/unit/ClusteredRedisQueue.spec.ts @@ -405,6 +405,89 @@ describe('ClusteredRedisQueue - EventEmitter proxy methods', () => { 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', () => { diff --git a/test/unit/RedisQueue.spec.ts b/test/unit/RedisQueue.spec.ts index c2974a1..ea6583a 100644 --- a/test/unit/RedisQueue.spec.ts +++ b/test/unit/RedisQueue.spec.ts @@ -1114,3 +1114,192 @@ describe('RedisQueue.unsubscribe()', () => { 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); + }); +}); From 99e710fe34a11287c43abd46d82ee46823e43b60 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Tue, 7 Jul 2026 00:07:51 +0200 Subject: [PATCH 11/20] fix: added files --- test/unit/helpers/buildOptions.spec.ts | 65 ++++++++++++++++++++++++++ test/unit/helpers/envInt.spec.ts | 60 ++++++++++++++++++++++++ test/unit/helpers/escapeRegExp.spec.ts | 55 ++++++++++++++++++++++ test/unit/helpers/pack.spec.ts | 63 +++++++++++++++++++++++++ test/unit/helpers/randomInt.spec.ts | 58 +++++++++++++++++++++++ test/unit/helpers/sha1.spec.ts | 45 ++++++++++++++++++ test/unit/helpers/unpack.spec.ts | 64 +++++++++++++++++++++++++ 7 files changed, 410 insertions(+) create mode 100644 test/unit/helpers/buildOptions.spec.ts create mode 100644 test/unit/helpers/envInt.spec.ts create mode 100644 test/unit/helpers/escapeRegExp.spec.ts create mode 100644 test/unit/helpers/pack.spec.ts create mode 100644 test/unit/helpers/randomInt.spec.ts create mode 100644 test/unit/helpers/sha1.spec.ts create mode 100644 test/unit/helpers/unpack.spec.ts 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/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..449dabd --- /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 '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); + }); +}); From 3f25ad4c0477a0d136e91a4d07ced655e783209a Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Tue, 7 Jul 2026 00:27:44 +0200 Subject: [PATCH 12/20] chore: refreshed package-lock.json --- package-lock.json | 92 +++++++++++++++++++++++------------------------ 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0f40730..d64c093 100644 --- a/package-lock.json +++ b/package-lock.json @@ -803,13 +803,13 @@ "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==", + "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": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/unist": { @@ -826,6 +826,29 @@ "dev": true, "license": "Python-2.0" }, + "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": "18 || 20 || >=22" + } + }, + "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": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1088,6 +1111,22 @@ "dev": true, "license": "MIT" }, + "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": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mock-require": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/mock-require/-/mock-require-3.0.3.tgz", @@ -1347,45 +1386,6 @@ "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/typedoc/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": "18 || 20 || >=22" - } - }, - "node_modules/typedoc/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": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/typedoc/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": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1408,9 +1408,9 @@ "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==", + "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" }, From 325bcb67970d20d46741ac9970b83b508bb87892 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Tue, 7 Jul 2026 01:12:40 +0200 Subject: [PATCH 13/20] fix: inspections on udp cluster manager --- src/ClusterManager.ts | 16 +- src/UDPClusterManager.ts | 335 ++++++++++++++++++++-------- src/UDPWorker.ts | 86 ++++--- test/mocks/dgram.ts | 6 + test/unit/ClusterManager.spec.ts | 43 ++++ test/unit/UDPClusterManager.spec.ts | 142 +++++++++--- 6 files changed, 479 insertions(+), 149 deletions(-) diff --git a/src/ClusterManager.ts b/src/ClusterManager.ts index ebf6a32..7400e6d 100644 --- a/src/ClusterManager.ts +++ b/src/ClusterManager.ts @@ -40,7 +40,6 @@ export abstract class ClusterManager { protected constructor() {} public init(cluster: ICluster): InitializedCluster { - // build a new object rather than mutating the caller's cluster const initializedCluster: InitializedCluster = { ...cluster, id: randomUUID(), @@ -51,10 +50,21 @@ export abstract class ClusterManager { 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( diff --git a/src/UDPClusterManager.ts b/src/UDPClusterManager.ts index c17aaab..852efd1 100644 --- a/src/UDPClusterManager.ts +++ b/src/UDPClusterManager.ts @@ -22,7 +22,7 @@ * to get commercial licensing options. */ import { ClusterManager, ICluster } from './ClusterManager'; -import { IServerInput } from './IMessageQueue'; +import { ILogger, IServerInput } from './IMessageQueue'; import { Worker } from 'worker_threads'; import { join } from 'path'; @@ -30,13 +30,7 @@ import { join } from 'path'; interface WorkerMessage { type?: string; server?: unknown; -} - -/** Minimal socket surface destroySocket() interacts with */ -interface DisposableSocket { - removeAllListeners?: () => void; - close?: (callback?: () => void) => void; - unref?: () => void; + error?: string; } export interface UDPClusterManagerOptions { @@ -82,33 +76,72 @@ 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 = {}; - /** Number of manager instances sharing each worker (by address:port) */ + /** Number of manager instances sharing each worker (by worker key) */ private static workerRefs: Record = {}; - public static sockets: 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; + private readonly options: UDPClusterManagerOptions; private workerKey!: string; private worker!: Worker; + private destroyed: boolean = false; constructor(options?: Partial) { super(); @@ -120,12 +153,39 @@ export class UDPClusterManager extends ClusterManager { this.startWorkerListener(); - UDPClusterManager.bindSignals(); + if (this.options.handleSignals) { + UDPClusterManager.bindSignals(); + } + } + + private get logger(): ILogger { + return this.options.logger; } /** - * Registers process-level shutdown handlers exactly once per process, - * so multiple manager instances do not accumulate duplicate listeners. + * 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. + * + * @access private + * @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. * * @access private */ @@ -136,12 +196,33 @@ export class UDPClusterManager extends ClusterManager { UDPClusterManager.signalsBound = true; - process.on('SIGTERM', UDPClusterManager.free); - process.on('SIGINT', UDPClusterManager.free); - process.on('SIGABRT', UDPClusterManager.free); + 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. + * + * @access private + * @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( @@ -154,30 +235,122 @@ export class UDPClusterManager extends ClusterManager { ); } + /** + * 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. + * + * @access private + */ private startWorkerListener(): void { - this.workerKey = `${this.options.address}:${this.options.port}`; + this.workerKey = UDPClusterManager.workerKeyFor(this.options); + UDPClusterManager.workerRefs[this.workerKey] = (UDPClusterManager.workerRefs[this.workerKey] || 0) + 1; + (UDPClusterManager.instances[this.workerKey] ??= new Set()).add(this); - if (UDPClusterManager.workers[this.workerKey]) { - this.worker = UDPClusterManager.workers[this.workerKey]; + this.worker = + UDPClusterManager.workers[this.workerKey] || this.spawnWorker(); + this.worker.on('message', this.onWorkerMessage); + } - return; - } + /** + * 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. + * + * @access private + * @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, + }); + const workerKey = this.workerKey; - this.worker = new Worker(join(__dirname, './UDPWorker.js'), { - workerData: this.options, + // many manager instances may listen on one shared worker + worker.setMaxListeners(0); + + worker.on('error', err => { + this.logger.error( + `UDPClusterManager: worker ${workerKey} error:`, + err, + ); }); - this.worker.on('message', (message: WorkerMessage) => { - void this.handleWorkerMessage(message); + worker.on('exit', code => { + if (UDPClusterManager.workers[workerKey] === worker) { + delete UDPClusterManager.workers[workerKey]; + } + + 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; + + return worker; + } + + /** + * 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. + * + * @access private + * @param {string} workerKey + */ + private static respawn(workerKey: string): void { + const instances = UDPClusterManager.instances[workerKey]; + + if (UDPClusterManager.shuttingDown || !instances?.size) { + return; + } + + 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); + } + }, 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. Errors from cluster callbacks are contained here, so the + * cluster. Cluster callback errors are contained per cluster, so the * worker message listener can never raise an unhandled rejection. * * @access private @@ -185,6 +358,14 @@ export class UDPClusterManager extends ClusterManager { * @returns {Promise} */ private async handleWorkerMessage(message: WorkerMessage): Promise { + if (message.type === 'error') { + this.logger.warn( + `UDPClusterManager: worker socket error: ${message.error}`, + ); + + return; + } + const [className, method] = String(message.type ?? '').split(':'); if (className !== 'cluster') { @@ -193,74 +374,45 @@ export class UDPClusterManager extends ClusterManager { const action = method as keyof ICluster; - try { - await this.anyCluster(cluster => { - const server = message.server as IServerInput; + await this.forEachCluster(cluster => { + const server = message.server as IServerInput; - if (action === 'add' && cluster.find(server)) { - return; - } + if (action === 'add' && cluster.find(server)) { + return; + } - const handler = cluster[action] as - | ((server: IServerInput) => unknown) - | undefined; + const handler = cluster[action] as + | ((server: IServerInput) => unknown) + | undefined; - handler?.(server); - }); - } catch { - // a failing cluster callback must not crash the listener - } + 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 on - // the same address:port — just release this reference + // 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); } - public static async destroySocket( - key: string, - socket?: DisposableSocket, - ): Promise { - if (!socket) { - return; - } - - if (typeof socket.removeAllListeners === 'function') { - socket.removeAllListeners(); - } - - const close = socket.close; - - if (typeof close !== 'function') { - return; - } - - await new Promise(resolve => { - close.call(socket, () => { - if (typeof socket.unref === 'function') { - socket.unref(); - } - if ( - UDPClusterManager.sockets && - key in UDPClusterManager.sockets - ) { - delete UDPClusterManager.sockets[key]; - } - resolve(); - }); - }); - } - private static async destroyWorker( workerKey: string, worker?: Worker, @@ -270,22 +422,29 @@ export class UDPClusterManager extends ClusterManager { } return new Promise(resolve => { - const timeout = setTimeout(() => { + const finish = (): void => { + worker.off('message', onMessage); + clearTimeout(timeout); 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 3433c12..b7b17d3 100644 --- a/src/UDPWorker.ts +++ b/src/UDPWorker.ts @@ -30,11 +30,9 @@ import { } from 'worker_threads'; import { createSocket, Socket } from 'dgram'; import { networkInterfaces } from 'os'; -import { UDPClusterManagerOptions } from './UDPClusterManager'; +import { UDPWorkerOptions } from './UDPClusterManager'; import { randomUUID } from 'crypto'; -process.setMaxListeners(10000); - enum MessageType { Up = 'up', Down = 'down', @@ -49,24 +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 { @@ -81,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', @@ -183,19 +194,44 @@ class UDPWorker { return defaultAddress; } - private parseMessage(input: Buffer): Message { + /** + * 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 [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, }; } @@ -213,15 +249,15 @@ class UDPWorker { this.messagePort.postMessage({ type: 'stopped' }); } - // arrow field so the bound reference can be passed to process signal - // handlers without losing `this` - private readonly cleanup = (): void => { + private cleanup(): void { 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'); } - }; + } } if (!isMainThread && parentPort) { diff --git a/test/mocks/dgram.ts b/test/mocks/dgram.ts index 4a01c3e..cb84932 100644 --- a/test/mocks/dgram.ts +++ b/test/mocks/dgram.ts @@ -38,6 +38,12 @@ class Socket extends EventEmitter { return this; } + + public close(callback?: () => void): void { + if (callback) { + callback(); + } + } } export const createSocket = () => { diff --git a/test/unit/ClusterManager.spec.ts b/test/unit/ClusterManager.spec.ts index a2808e3..e3f96c1 100644 --- a/test/unit/ClusterManager.spec.ts +++ b/test/unit/ClusterManager.spec.ts @@ -58,3 +58,46 @@ describe('ClusterManager.remove()', () => { 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/UDPClusterManager.spec.ts b/test/unit/UDPClusterManager.spec.ts index ea3d9e4..b54a595 100644 --- a/test/unit/UDPClusterManager.spec.ts +++ b/test/unit/UDPClusterManager.spec.ts @@ -187,52 +187,125 @@ describe('UDPBroadcastClusterManager', () => { }); describe('destroy()', () => { - it('should handle empty sockets gracefully', async () => { + it('should be idempotent', async () => { const manager: any = new UDPClusterManager(); - // Clear any existing sockets - (UDPClusterManager as any).sockets = {}; - - // Should not throw when no sockets exist + await manager.destroy(); + // a repeated destroy must not throw or corrupt refcounts await manager.destroy(); assert.equal( - Object.keys((UDPClusterManager as any).sockets).length, - 0, + (UDPClusterManager as any).workerRefs[manager.workerKey], + undefined, ); }); }); }); -describe('UDPClusterManager - cover remaining branches', () => { - it('destroySocket should call socket.unref() when socket is present', async () => { - // Prepare fake socket with unref - const unref = mock.fn(); - const removeAll = mock.fn(); - const sock: any = { - removeAllListeners: removeAll, - close: (cb: (err?: any) => void) => cb(), - unref, +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 key = 'test-key'; - (UDPClusterManager as any).sockets[key] = sock; - await (UDPClusterManager as any).destroySocket(key, sock); - assert.equal(unref.mock.callCount() > 0, true); - assert.equal((UDPClusterManager as any).sockets[key], 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('destroySocket should work when socket.unref() is absent (optional chaining negative branch)', async () => { - const removeAll = mock.fn(); - const sock: any = { - removeAllListeners: removeAll, - close: (cb: (err?: any) => void) => cb(), - // no unref method + 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 key = 'test-key-2'; - (UDPClusterManager as any).sockets[key] = sock; - await (UDPClusterManager as any).destroySocket(key, sock); - // should not throw, sockets map cleaned - assert.equal((UDPClusterManager as any).sockets[key], 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 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 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; }); }); @@ -253,11 +326,14 @@ describe('UDPClusterManager.destroyWorker()', () => { let terminated = false; const fakeWorker: any = { postMessage: () => {}, - once: (event: string, cb: Function) => { + 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; }, From e8d4ad8726f50cb9c77d20ba086a76e23da1d6ea Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Tue, 7 Jul 2026 01:12:53 +0200 Subject: [PATCH 14/20] fix: added files --- test/unit/UDPWorker.spec.ts | 201 ++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 test/unit/UDPWorker.spec.ts diff --git a/test/unit/UDPWorker.spec.ts b/test/unit/UDPWorker.spec.ts new file mode 100644 index 0000000..df73f03 --- /dev/null +++ b/test/unit/UDPWorker.spec.ts @@ -0,0 +1,201 @@ +/*! + * 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 '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 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']); + }); +}); From f49c0587f9e44d6f6cf1ac285a251a46a26460d2 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Tue, 7 Jul 2026 01:37:50 +0200 Subject: [PATCH 15/20] fix: UDP CLuster Manager exit issues --- src/RedisQueue.ts | 59 +++++++++++++++++++++++++---- src/UDPClusterManager.ts | 18 +++++++++ test/unit/RedisQueue.spec.ts | 4 +- test/unit/UDPClusterManager.spec.ts | 22 +++++++++++ test/unit/profile.spec.ts | 11 +++--- 5 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/RedisQueue.ts b/src/RedisQueue.ts index 31ec56a..0a70c1a 100644 --- a/src/RedisQueue.ts +++ b/src/RedisQueue.ts @@ -96,6 +96,17 @@ export const DEFAULT_IMQ_OPTIONS: IMQOptions = { export const IMQ_SHUTDOWN_TIMEOUT = envInt('IMQ_SHUTDOWN_TIMEOUT', 1000); +/** + * 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 const IMQ_CONNECTION_QUIT_TIMEOUT = envInt( + 'IMQ_CONNECTION_QUIT_TIMEOUT', + 1000, +); + type RedisConnectionChannel = 'reader' | 'writer' | 'watcher' | 'subscription'; const IMQ_REDIS_MAX_LISTENERS_LIMIT = envInt( @@ -1090,12 +1101,36 @@ export class RedisQueue try { client.removeAllListeners(); - client - .quit() - .then(() => client.disconnect(false)) - .catch((error: unknown) => - this.verbose(`Error quitting ${channel}: ${error}`), - ); + + let disconnected = false; + const forceDisconnect = (): void => { + if (disconnected) { + return; + } + + disconnected = true; + + try { + client.disconnect(false); + } 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}`); } @@ -1758,9 +1793,17 @@ export class RedisQueue this.process(msg); } } catch (err) { + // 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 ( - err instanceof Error && - err.message.match(/Stream connection ended/) + this.destroyed || + !this.reader || + (err instanceof Error && + /Stream connection ended|Connection is closed/i.test( + err.message, + )) ) { break; } diff --git a/src/UDPClusterManager.ts b/src/UDPClusterManager.ts index 852efd1..5ab5669 100644 --- a/src/UDPClusterManager.ts +++ b/src/UDPClusterManager.ts @@ -138,6 +138,13 @@ export class UDPClusterManager extends ClusterManager { /** 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; @@ -289,6 +296,14 @@ export class UDPClusterManager extends ClusterManager { delete UDPClusterManager.workers[workerKey]; } + // 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); + + return; + } + if (code !== 0 && !UDPClusterManager.shuttingDown) { this.logger.warn( `UDPClusterManager: worker ${workerKey} exited ` + @@ -425,6 +440,9 @@ export class UDPClusterManager extends ClusterManager { 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(); if (UDPClusterManager.workers[workerKey] === worker) { diff --git a/test/unit/RedisQueue.spec.ts b/test/unit/RedisQueue.spec.ts index ea6583a..25be59b 100644 --- a/test/unit/RedisQueue.spec.ts +++ b/test/unit/RedisQueue.spec.ts @@ -126,7 +126,7 @@ describe('RedisQueue', () => { try { await rq.start(); await rq.start(); - } catch (err) { + } catch { passed = false; } assert.equal(passed, true); @@ -1043,7 +1043,7 @@ describe('RedisQueue.send() extra branches', () => { IMQMode.PUBLISHER, ); // Force start to be a no-op so writer remains undefined - const startStub = mock.method(rq, 'start', async () => rq); + mock.method(rq, 'start', async () => rq); let thrown: any; try { diff --git a/test/unit/UDPClusterManager.spec.ts b/test/unit/UDPClusterManager.spec.ts index b54a595..61f5610 100644 --- a/test/unit/UDPClusterManager.spec.ts +++ b/test/unit/UDPClusterManager.spec.ts @@ -293,6 +293,28 @@ describe('UDPClusterManager - shared worker fan-out', () => { 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; diff --git a/test/unit/profile.spec.ts b/test/unit/profile.spec.ts index e546bec..a945549 100644 --- a/test/unit/profile.spec.ts +++ b/test/unit/profile.spec.ts @@ -38,7 +38,7 @@ import { logger } from '../mocks'; const BIG_INT_SUPPORT = (() => { try { return !!BigInt(0); - } catch (err) { + } catch { return false; } })(); @@ -102,14 +102,13 @@ class ProfiledClassTimedAndArgued { describe('profile()', () => { let log: Mock; let error: Mock; - let warn: Mock; - let info: Mock; beforeEach(() => { log = mock.method(logger, 'log'); error = mock.method(logger, 'error'); - warn = mock.method(logger, 'warn'); - info = mock.method(logger, 'info'); + // spied to suppress profiler output during tests (not asserted on) + mock.method(logger, 'warn'); + mock.method(logger, 'info'); }); afterEach(() => { @@ -336,7 +335,7 @@ describe('profile() async rejection path', () => { obj.logger = logger; try { await obj.willReject(); - } catch (e) { + } catch { // expected } // allow microtask queue From bcc87859bf5793a4ec8f829d1a7efb8b499b8458 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Tue, 7 Jul 2026 01:47:32 +0200 Subject: [PATCH 16/20] fix: remove await writes option --- src/IMessageQueue.ts | 11 ----------- src/RedisQueue.ts | 30 ------------------------------ test/unit/RedisQueue.spec.ts | 17 ----------------- 3 files changed, 58 deletions(-) diff --git a/src/IMessageQueue.ts b/src/IMessageQueue.ts index d17c044..6c0d707 100644 --- a/src/IMessageQueue.ts +++ b/src/IMessageQueue.ts @@ -280,17 +280,6 @@ export interface IMQOptions extends Partial { */ handleSignals?: boolean; - /** - * When enabled, send() resolves only after the message writing is - * confirmed by redis (and rejects on writing failures). By default, - * writes are fire-and-forget for maximum throughput, and failures are - * reported through the optional errorHandler argument only. - * - * @default false - * @type {boolean} - */ - awaitWrites?: boolean; - /** * Enables/disables verbose logging * diff --git a/src/RedisQueue.ts b/src/RedisQueue.ts index 0a70c1a..2e030d0 100644 --- a/src/RedisQueue.ts +++ b/src/RedisQueue.ts @@ -91,7 +91,6 @@ export const DEFAULT_IMQ_OPTIONS: IMQOptions = { useGzip: false, watcherCheckDelay: 5000, handleSignals: true, - awaitWrites: false, }; export const IMQ_SHUTDOWN_TIMEOUT = envInt('IMQ_SHUTDOWN_TIMEOUT', 1000); @@ -707,35 +706,6 @@ export class RedisQueue } }; - if (this.options.awaitWrites) { - // confirmed writes mode: resolve after redis accepted the - // message, reject on 'write' failures - try { - if (delay) { - await this.writer.zadd( - `${key}:delayed`, - Date.now() + delay, - packet, - ); - await this.writer.set( - `${key}:${id}:ttl`, - '', - 'PX', - delay, - 'NX', - ); - } else { - await this.writer.lpush(key, packet); - } - } catch (err) { - onWriteError(err, delay ? 'ZADD/SET' : 'LPUSH'); - - throw err; - } - - return id; - } - if (delay) { this.writer.zadd( `${key}:delayed`, diff --git a/test/unit/RedisQueue.spec.ts b/test/unit/RedisQueue.spec.ts index 25be59b..81df2dd 100644 --- a/test/unit/RedisQueue.spec.ts +++ b/test/unit/RedisQueue.spec.ts @@ -518,23 +518,6 @@ describe('RedisQueue error handling', () => { assert.equal(evalCalls.length, 1, 'EVAL fallback must be used'); }); - it('send() rejects on write failure when awaitWrites is enabled', async t => { - const logger = makeLogger(); - const rq: any = new RedisQueue( - 'AwaitW', - { logger, awaitWrites: true }, - IMQMode.PUBLISHER, - ); - await rq.start(); - t.after(() => rq.destroy().catch(() => undefined)); - - mock.method(rq.writer, 'lpush', () => - Promise.reject(new Error('write failed')), - ); - - await assert.rejects(rq.send('AwaitW', { a: 1 }), /write failed/); - }); - it('escapes regex metacharacters (escapeRegExp)', () => { assert.equal(escapeRegExp('my.app*x?'), 'my\\.app\\*x\\?'); assert.equal(escapeRegExp('plain'), 'plain'); From aac74a786803958e05a4e40ba437c5810fda9db4 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Tue, 7 Jul 2026 17:58:57 +0200 Subject: [PATCH 17/20] chore: docs, coverage and build hygiene - Make tsc idempotent: clean compiled artifacts before every build via a clean-compiled helper routed through build/prepare/test, fixing TS5055 ("would overwrite input file") on repeated builds and npm install. - Drop the `open` dependency; the doc and benchmark flows now print a file:// link instead of auto-launching a browser. - Add typedoc.json (name, excludes, externalSymbolLinkMappings) for a zero-warning docs build; the doc script prints a file:// link. - Remove obsolete @access/@constructor JSDoc tags (redundant with TS visibility keywords). - Add TypeScript-mapped coverage (--enable-source-maps) and a test-coverage-html report script that prints a file:// link. - Remove redundant mocha/nyc/typescript package.json blocks. - Make isThenable generic so downstream stricter configs compile. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S7M9ZZCRc9SUFAchcuPLw6 --- benchmark/index.ts | 6 +- package-lock.json | 188 ------------------------------------- package.json | 49 ++-------- src/ClusteredRedisQueue.ts | 4 - src/RedisQueue.ts | 41 -------- src/UDPClusterManager.ts | 9 -- src/profile.ts | 11 ++- typedoc.json | 19 ++++ 8 files changed, 36 insertions(+), 291 deletions(-) create mode 100644 typedoc.json diff --git a/benchmark/index.ts b/benchmark/index.ts index f0dc8f8..d11aae6 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -423,11 +423,7 @@ function saveStats({ metrics, memusage }: any, data: any[]) { writeFileSync(htmlFile, html, { encoding: 'utf8' }); console.info('Benchmark stats saved!'); - console.info(`Opening file://${htmlFile}`); - - import('open').then(open => - open.default(`file://${htmlFile}`, { wait: false }), - ); + console.info(`Open in browser: file://${htmlFile}`); process.exit(0); } diff --git a/package-lock.json b/package-lock.json index d64c093..5a0dca8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@types/mock-require": "^3.0.0", "@types/node": "^24.9.1", "mock-require": "^3.0.3", - "open": "^11.0.0", "oxfmt": "0.57.0", "oxlint": "1.72.0", "typedoc": "^0.28.20", @@ -849,22 +848,6 @@ "node": "18 || 20 || >=22" } }, - "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/cluster-key-slot": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", @@ -891,49 +874,6 @@ } } }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "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.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "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", @@ -985,70 +925,6 @@ "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-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/linkify-it": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", @@ -1160,27 +1036,6 @@ "node": ">=0.10.0" } }, - "node_modules/open": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.4.0", - "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", - "is-inside-container": "^1.0.0", - "powershell-utils": "^0.1.0", - "wsl-utils": "^0.3.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/oxfmt": { "version": "0.57.0", "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.57.0.tgz", @@ -1282,19 +1137,6 @@ } } }, - "node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -1333,19 +1175,6 @@ "dev": true, "license": "ISC" }, - "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/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -1414,23 +1243,6 @@ "dev": true, "license": "MIT" }, - "node_modules/wsl-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", - "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index 562a02f..22fe8ef 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,16 @@ ], "scripts": { "benchmark": "node benchmark -c $(( $(nproc) - 2 )) -m 100000", - "build": "tsc", - "prepare": "tsc", + "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": "tsc && node --test --test-timeout=15000 $(find test -name '*.spec.js')", - "test-coverage": "tsc && node --test --experimental-test-coverage --test-timeout=15000 $(find test -name '*.spec.js')", - "test-lcov": "tsc && mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-timeout=15000 $(find test -name '*.spec.js')", + "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')", + "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", "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", @@ -28,7 +30,7 @@ "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", @@ -47,44 +49,11 @@ "@types/mock-require": "^3.0.0", "@types/node": "^24.9.1", "mock-require": "^3.0.3", - "open": "^11.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/src/ClusteredRedisQueue.ts b/src/ClusteredRedisQueue.ts index 9979f22..073d73f 100644 --- a/src/ClusteredRedisQueue.ts +++ b/src/ClusteredRedisQueue.ts @@ -145,7 +145,6 @@ export class ClusteredRedisQueue /** * Class constructor * - * @constructor * @param {string} name * @param {Partial} options * @param {IMQMode} [_mode] @@ -260,7 +259,6 @@ export class ClusteredRedisQueue * to a host that is known to be down. Falls back to the plain * round-robin pick when no instance reports are ready. * - * @access private * @returns {RedisQueue} */ private selectQueue(): RedisQueue { @@ -288,7 +286,6 @@ export class ClusteredRedisQueue * Rejects (rather than hanging forever) if none appears within the * configured timeout and propagates any sent failure. * - * @access private * @returns {Promise} */ private sendWhenInitialized( @@ -385,7 +382,6 @@ export class ClusteredRedisQueue /** * Batch imq action processing on all registered imqs at once * - * @access private * @param {string} action * @param {string} message * @returns {Promise} diff --git a/src/RedisQueue.ts b/src/RedisQueue.ts index 2e030d0..60e93b5 100644 --- a/src/RedisQueue.ts +++ b/src/RedisQueue.ts @@ -302,7 +302,6 @@ export class RedisQueue private readonly unpack: (data: string) => unknown; /** - * @constructor * @param {string} name * @param {IMQOptions} [options] * @param {IMQMode} [mode] @@ -385,7 +384,6 @@ export class RedisQueue * Attaches a subscription message handler to a given channel * connection * - * @access private * @param {IRedisClient} chan * @param {(data: JsonObject) => void} handler */ @@ -413,7 +411,6 @@ export class RedisQueue * connection. Used after a connection replacement on reconnection, * otherwise the new connection would silently stay unsubscribed. * - * @access private * @returns {Promise} */ private async restoreSubscription(): Promise { @@ -552,8 +549,6 @@ export class RedisQueue /** * Binds process-level signal handlers once per process. On shutdown * signals, frees watcher locks held by any queue instance and exits. - * - * @access private */ private static bindSignals(): void { if (RedisQueue.signalsBound) { @@ -575,7 +570,6 @@ export class RedisQueue * Frees watcher locks held by all started queue instances and exits * the process, forcing exit after IMQ_SHUTDOWN_TIMEOUT at the latest. * - * @access private * @returns {Promise} */ private static async freeAndExit(): Promise { @@ -604,8 +598,6 @@ export class RedisQueue * 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. - * - * @access private */ private startWatcherCheck(): void { if (this.watcherCheckInterval || !this.options.watcherCheckDelay) { @@ -624,7 +616,6 @@ export class RedisQueue * none exists and moves due to delayed messages as a keyspace-notification * fallback. Errors are contained so the interval never crashes. * - * @access private * @returns {Promise} */ private async runWatcherCheck(): Promise { @@ -651,8 +642,6 @@ export class RedisQueue /** * Stops the periodic watcher check - * - * @access private */ private stopWatcherCheck(): void { if (this.watcherCheckInterval) { @@ -936,7 +925,6 @@ export class RedisQueue /** * Returns the connection currently bound to the given channel, if any. * - * @access private * @param {RedisConnectionChannel} channel * @returns {IRedisClient | undefined} */ @@ -958,7 +946,6 @@ export class RedisQueue /** * Binds the given connection to the given channel. * - * @access private * @param {RedisConnectionChannel} channel * @param {IRedisClient} conn */ @@ -993,7 +980,6 @@ export class RedisQueue /** * Return a lock key for watcher connection * - * @access private * @returns {string} */ private get lockKey(): string { @@ -1003,7 +989,6 @@ export class RedisQueue /** * Returns current queue key * - * @access private * @returns {string} */ private get key(): string { @@ -1012,8 +997,6 @@ export class RedisQueue /** * Destroys watcher channel - * - * @access private */ @profile() private destroyWatcher(): void { @@ -1027,8 +1010,6 @@ export class RedisQueue /** * Destroys writer channel - * - * @access private */ @profile() private destroyWriter(release: boolean = true): void { @@ -1058,8 +1039,6 @@ export class RedisQueue /** * Destroys any channel - * - * @access private */ @profile() private destroyChannel(channel: RedisConnectionChannel): void { @@ -1109,7 +1088,6 @@ export class RedisQueue /** * Establishes a given connection channel by its name * - * @access private * @param {RedisConnectionChannel} channel * @param {IMQOptions} options * @returns {Promise} @@ -1232,7 +1210,6 @@ export class RedisQueue * rescheduling itself on failure. Errors are handled internally, so the * scheduled timer never produces an unhandled rejection. * - * @access private * @param {RedisConnectionChannel} channel * @returns {Promise} */ @@ -1301,7 +1278,6 @@ export class RedisQueue /** * Builds and returns connection error handler * - * @access private * @param {RedisConnectionChannel} channel * @returns {(err: Error) => void} */ @@ -1335,7 +1311,6 @@ export class RedisQueue /** * Builds and returns redis connection close handler * - * @access private * @param {RedisConnectionChannel} channel * @returns {() => void} */ @@ -1362,7 +1337,6 @@ export class RedisQueue /** * Processes given redis-queue message * - * @access private * @param {[string, string]} msg * @returns {RedisQueue} */ @@ -1391,7 +1365,6 @@ export class RedisQueue /** * Returns the number of established watcher connections on redis * - * @access private * @returns {Promise} */ private async watcherCount(): Promise { @@ -1414,7 +1387,6 @@ export class RedisQueue /** * Processes delayed a message by its given redis key * - * @access private * @param {string} key * @returns {Promise} */ @@ -1460,7 +1432,6 @@ export class RedisQueue /** * Watch routine * - * @access private * @returns {Promise} */ private async processWatch(): Promise { @@ -1500,7 +1471,6 @@ export class RedisQueue /** * Process given keys from a message queue * - * @access private * @param {string[]} keys * @param {number} now * @returns {Promise} @@ -1539,7 +1509,6 @@ export class RedisQueue /** * Watch message processor * - * @access private * @param {...any[]} args * @returns {Promise} */ @@ -1561,8 +1530,6 @@ export class RedisQueue /** * Clears safe check interval - * - * @access private */ private cleanSafeCheckInterval(): void { if (this.safeCheckInterval) { @@ -1574,7 +1541,6 @@ export class RedisQueue /** * Setups watch a process on delayed messages * - * @access private * @returns {RedisQueue} */ private watch(): RedisQueue { @@ -1621,7 +1587,6 @@ export class RedisQueue * A single safe-delivery maintenance tick: recovers messages from dead * workers (when safe delivery is on) and prunes orphaned keys. * - * @access private * @returns {Promise} */ private async runSafeCheck(): Promise { @@ -1641,7 +1606,6 @@ export class RedisQueue /** * Cleans up orphaned keys from redis * - * @access private * @returns {Promise} */ private async processCleanup(): Promise { @@ -1871,7 +1835,6 @@ export class RedisQueue /** * Checks if the watcher connection is locked * - * @access private * @returns {Promise} */ private async isLocked(): Promise { @@ -1885,7 +1848,6 @@ export class RedisQueue /** * Locks watcher connection * - * @access private * @returns {Promise} */ private async lock(): Promise { @@ -1899,7 +1861,6 @@ export class RedisQueue /** * Unlocks watcher connection * - * @access private * @returns {Promise} */ private async unlock(): Promise { @@ -1913,7 +1874,6 @@ export class RedisQueue /** * Emits error * - * @access private * @param {string} eventName * @param {string} message * @param {unknown} err @@ -1985,7 +1945,6 @@ export class RedisQueue * but no watcher connection is actually alive, releases and re-acquires * ownership. Used to resolve a possible watcher deadlock. * - * @access private * @returns {Promise} */ private async resolveWatchLock(): Promise { diff --git a/src/UDPClusterManager.ts b/src/UDPClusterManager.ts index 5ab5669..c39fef5 100644 --- a/src/UDPClusterManager.ts +++ b/src/UDPClusterManager.ts @@ -174,7 +174,6 @@ export class UDPClusterManager extends ClusterManager { * depends on, so managers configured differently never silently share * a worker built from another manager's options. * - * @access private * @param {UDPClusterManagerOptions} options * @returns {string} */ @@ -193,8 +192,6 @@ export class UDPClusterManager extends ClusterManager { * 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. - * - * @access private */ private static bindSignals(): void { if (UDPClusterManager.signalsBound) { @@ -216,7 +213,6 @@ export class UDPClusterManager extends ClusterManager { * Stops all workers and re-raises the given signal, so the process * terminates through the default signal behavior. * - * @access private * @param {NodeJS.Signals} signal * @returns {Promise} */ @@ -246,8 +242,6 @@ export class UDPClusterManager extends ClusterManager { * 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. - * - * @access private */ private startWorkerListener(): void { this.workerKey = UDPClusterManager.workerKeyFor(this.options); @@ -266,7 +260,6 @@ export class UDPClusterManager extends ClusterManager { * unexpected worker death the worker is dropped from the registry, and * a re-spawn is scheduled while live manager instances remain. * - * @access private * @returns {Worker} */ private spawnWorker(): Worker { @@ -323,7 +316,6 @@ export class UDPClusterManager extends ClusterManager { * live manager instances to it, so cluster membership does not silently * freeze after a worker crash. * - * @access private * @param {string} workerKey */ private static respawn(workerKey: string): void { @@ -368,7 +360,6 @@ export class UDPClusterManager extends ClusterManager { * cluster. Cluster callback errors are contained per cluster, so the * worker message listener can never raise an unhandled rejection. * - * @access private * @param {WorkerMessage} message * @returns {Promise} */ diff --git a/src/profile.ts b/src/profile.ts index b200602..db119bd 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -245,12 +245,15 @@ function resolveLogger(target: unknown): ILogger | undefined { } /** - * Type guard detecting a thenable (promise-like) value. + * 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 {unknown} value - * @returns {value is PromiseLike} + * @param {T} value + * @returns {value is T & PromiseLike} */ -function isThenable(value: unknown): value is PromiseLike { +function isThenable(value: T): value is T & PromiseLike { return ( (typeof value === 'object' || typeof value === 'function') && value !== null && 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" +} From 3e5a0e58e7b67da753b5a6202eb62e61f321b4fd Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Tue, 7 Jul 2026 22:34:02 +0200 Subject: [PATCH 18/20] feat: dual-mode @profile decorator and restore uuid() export - Make @profile work as both a standard (TC39) and a legacy (experimentalDecorators) decorator via runtime mode detection, so services that must keep legacy decorators (e.g. sequelize-typescript consumers) can still use it alongside @imqueue/rpc's standard decorators. - Restore the public uuid() helper (src/uuid.ts) and its export, dropped during the earlier modernization; downstream services rely on it via @imqueue/rpc re-export. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S7M9ZZCRc9SUFAchcuPLw6 --- src/index.ts | 1 + src/profile.ts | 33 +++++++++++-------------- src/uuid.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 src/uuid.ts diff --git a/src/index.ts b/src/index.ts index 8058e24..3c96869 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ */ export * from './helpers'; export * from './profile'; +export * from './uuid'; export * from './redis'; export * from './IMQMode'; export * from './IMessageQueue'; diff --git a/src/profile.ts b/src/profile.ts index db119bd..6529f28 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -288,15 +288,7 @@ function isThenable(value: T): value is T & PromiseLike { descriptor: TypedPropertyDescriptor<(...args: any[]) => any> * ) => void} */ -export function profile( - options?: ProfileDecoratorOptions, -): ( - original: (this: This, ...args: Args) => Return, - context: ClassMethodDecoratorContext< - This, - (this: This, ...args: Args) => Return - >, -) => (this: This, ...args: Args) => Return { +export function profile(options?: ProfileDecoratorOptions): any { options = Object.assign({}, DEFAULT_OPTIONS, options); const { enableDebugTime, enableDebugArgs, logLevel } = options; @@ -311,16 +303,8 @@ export function profile( debugArgs = enableDebugArgs; } - return function decorator( - original: (this: This, ...args: Args) => Return, - context: ClassMethodDecoratorContext< - This, - (this: This, ...args: Args) => Return - >, - ): (this: This, ...args: Args) => Return { - const methodName = String(context.name); - - return function wrapper(this: This, ...args: Args): Return { + const wrap = (original: (...args: any[]) => any, methodName: string) => + function wrapper(this: any, ...args: any[]): any { if (!(debugTime || debugArgs)) { return original.apply(this, args); } @@ -353,5 +337,16 @@ export function profile( 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/uuid.ts b/src/uuid.ts new file mode 100644 index 0000000..18fe85f --- /dev/null +++ b/src/uuid.ts @@ -0,0 +1,65 @@ +/*! + * 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]; +} From 9c0ee71c1a2ca047be18b4dac664ed085f0d1b96 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Tue, 7 Jul 2026 22:47:03 +0200 Subject: [PATCH 19/20] fix: removed uuid export --- src/index.ts | 1 - src/uuid.ts | 65 ---------------------------------------------------- 2 files changed, 66 deletions(-) delete mode 100644 src/uuid.ts diff --git a/src/index.ts b/src/index.ts index 3c96869..8058e24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,6 @@ */ export * from './helpers'; export * from './profile'; -export * from './uuid'; export * from './redis'; export * from './IMQMode'; export * from './IMessageQueue'; 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]; -} From 088ef0849691d7bf7ee75b48b253613d9ba5e3e7 Mon Sep 17 00:00:00 2001 From: Serhiy Morenko Date: Tue, 7 Jul 2026 23:04:26 +0200 Subject: [PATCH 20/20] ci: run a plain test check, drop Coveralls upload Remove the Coveralls coverage-upload step and run `npm test` instead of `npm run test-lcov`; the workflow is now a simple lint/format + test gate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S7M9ZZCRc9SUFAchcuPLw6 --- .github/workflows/build.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db3ff13..c564f7b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,10 +59,4 @@ jobs: run: npm ci - name: Run tests - run: npm run test-lcov - - - name: Upload coverage to Coveralls - if: matrix.node-version == '24.x' - uses: coverallsapp/github-action@v2 - with: - file: coverage/lcov.info + run: npm test