diff --git a/.npmignore b/.npmignore index 5168a36..bcbf197 100644 --- a/.npmignore +++ b/.npmignore @@ -1,6 +1,7 @@ .gitignore .travis.yml .dockerignore +.github .ssh/ dist/ @@ -39,3 +40,14 @@ test/ !*.d.ts *.tgz + +# Dev/tooling — never part of the published package. +# NOTE: tsconfig.json is intentionally NOT ignored — IMQClient reads it at +# runtime (require('../tsconfig.json')) to transpile generated clients. +scripts/ +.oxlintrc.json +.oxfmtrc.json +.agent-out/ +# debug* are manual dev scripts; `!*.d.ts` above re-includes their declaration +# files, so re-exclude those here (a later rule wins) +debug*.d.ts diff --git a/package-lock.json b/package-lock.json index f9881cc..564c558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,44 +1,28 @@ { "name": "@imqueue/rpc", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@imqueue/rpc", - "version": "3.0.0", + "version": "3.1.0", "license": "GPL-3.0-only", "dependencies": { - "@imqueue/core": "^3.0.0", - "typescript": "^6.0.3" + "@imqueue/core": "^3.1.0", + "acorn": "^8.17.0", + "typescript": "^7.0.2" }, "devDependencies": { - "@types/mock-require": "^3.0.0", "@types/node": "^24.9.1", - "mock-require": "^3.0.3", "oxfmt": "0.57.0", - "oxlint": "1.72.0", - "typedoc": "^0.28.20" - } - }, - "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": { - "@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" + "oxlint": "1.72.0" } }, "node_modules/@imqueue/core": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@imqueue/core/-/core-3.0.0.tgz", - "integrity": "sha512-D1B78GfWVXeHmbRR3t97YGyYt5Anpi8Cl5xsU0mYGrMfqxLhnacUKAMYKcHU0STaEkdmf9wmiT7N0X8QkKqWHg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@imqueue/core/-/core-3.1.0.tgz", + "integrity": "sha512-lz8AQU9RgRW48AXfAKthYXO3KqU5nvhjhbqQwu34muY7TiJilIkIVGsBSQp58lI4HjOgk3wLUrnbH9rJNJI4bQ==", "license": "GPL-3.0-only", "dependencies": { "ioredis": "^5.11.1" @@ -744,117 +728,346 @@ "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2" + "undici-types": "~7.18.0" } }, - "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": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@shikijs/themes": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", - "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" } }, - "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": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } }, - "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/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } }, - "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/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.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", + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "18 || 20 || >=22" + "node": ">=16.20.0" } }, - "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, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=0.4.0" } }, "node_modules/cluster-key-slot": { @@ -892,26 +1105,6 @@ "node": ">=0.10" } }, - "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/get-caller-file": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", - "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", - "dev": true, - "license": "ISC" - }, "node_modules/ioredis": { "version": "5.11.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", @@ -934,117 +1127,12 @@ "url": "https://opencollective.com/ioredis" } }, - "node_modules/linkify-it": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", - "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "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/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": { - "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/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", - "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/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/oxfmt": { "version": "0.57.0", "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.57.0.tgz", @@ -1146,16 +1234,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/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -1177,13 +1255,6 @@ "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/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -1200,72 +1271,46 @@ "node": "^20.0.0 || >=22.0.0" } }, - "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": "Apache-2.0", - "dependencies": { - "@gerrit0/mini-shiki": "^3.23.0", - "lunr": "^2.3.9", - "markdown-it": "^14.3.0", - "minimatch": "^10.2.5", - "yaml": "^2.9.0" - }, - "bin": { - "typedoc": "bin/typedoc" - }, - "engines": { - "node": ">= 18", - "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/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "dev": true, - "license": "MIT" - }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" - }, - "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": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } } } } diff --git a/package.json b/package.json index 64e27db..a8f08b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@imqueue/rpc", - "version": "3.0.0", + "version": "3.1.0", "description": "RPC-like client-service implementation over messaging queue", "keywords": [ "redis", @@ -20,19 +20,17 @@ "lint": "oxlint", "format": "oxfmt \"index.ts\" \"debug.ts\" \"src/**/*.ts\" \"test/**/*.ts\"", "format:check": "oxfmt --check \"index.ts\" \"debug.ts\" \"src/**/*.ts\" \"test/**/*.ts\"", - "test": "npm run build && node --test --test-timeout=15000 $(find test -name '*.spec.js')", - "test-coverage": "npm run build && node --enable-source-maps --test --experimental-test-coverage --test-timeout=15000 $(find test -name '*.spec.js')", - "test-lcov": "npm run build && mkdir -p coverage && node --enable-source-maps --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-timeout=15000 $(find test -name '*.spec.js'); node scripts/strip-comment-coverage.mjs coverage/lcov.info", + "test": "npm run build && node --experimental-test-module-mocks --test --test-timeout=15000 $(find test -name '*.spec.js')", + "test-coverage": "npm run build && node --experimental-test-module-mocks --enable-source-maps --test --experimental-test-coverage --test-timeout=15000 $(find test -name '*.spec.js')", + "test-lcov": "npm run build && mkdir -p coverage && node --experimental-test-module-mocks --enable-source-maps --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-timeout=15000 $(find test -name '*.spec.js'); node scripts/strip-comment-coverage.mjs coverage/lcov.info", "test-coverage-html": "npm run test-lcov; genhtml coverage/lcov.info --output-directory coverage/html --ignore-errors inconsistent,corrupt,format,mismatch && echo \"Coverage report: file://$(pwd)/coverage/html/index.html\"", "test-dev": "npm run test && npm run clean-js && npm run clean-typedefs && npm run clean-maps", "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-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": "typedoc && echo \"Docs generated: file://$(pwd)/docs/index.html\"" + "clean": "npm run clean-tests && npm run clean-typedefs && npm run clean-maps && npm run clean-js && npm run clean-benchmark" }, "repository": { "type": "git", @@ -45,17 +43,21 @@ "author": "imqueue.com (https://imqueue.com)", "license": "GPL-3.0-only", "dependencies": { - "@imqueue/core": "^3.0.0", - "typescript": "^6.0.3" + "@imqueue/core": "^3.1.0", + "acorn": "^8.17.0", + "typescript": "^7.0.2" }, "devDependencies": { - "@types/mock-require": "^3.0.0", "@types/node": "^24.9.1", - "mock-require": "^3.0.3", "oxfmt": "0.57.0", - "oxlint": "1.72.0", - "typedoc": "^0.28.20" + "oxlint": "1.72.0" }, "main": "index.js", - "types": "index.d.ts" + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + } } diff --git a/scripts/strip-comment-coverage.mjs b/scripts/strip-comment-coverage.mjs index 8aa52ec..903ac4e 100644 --- a/scripts/strip-comment-coverage.mjs +++ b/scripts/strip-comment-coverage.mjs @@ -38,13 +38,31 @@ * This script rewrites an lcov file in place: it drops the `DA`/`BRDA` records * for lines that contain no executable code (comments and blank lines), drops * all function records and malformed branch records, then recomputes the - * `LF`/`LH`/`BRF`/`BRH` totals. It uses the TypeScript scanner (already a dev - * dependency) to locate comments reliably — comment markers inside strings, - * template literals and regexes are not misdetected. The result renders in - * genhtml with accurate line + branch coverage and no warnings or errors. + * `LF`/`LH`/`BRF`/`BRH` totals. Comments are located with the TypeScript + * scanner (typescript is already a dependency) in trivia-emitting mode, with + * regex/template re-scanning so comment markers inside strings, template + * literals and regexes are not misdetected; statement structure (imports, + * variable statements, class headers) comes from the TypeScript 7 API server + * (`typescript/unstable/sync`), which parses the project's real sources. The + * result renders in genhtml with accurate line + branch coverage and no + * warnings or errors. */ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import ts from 'typescript'; +import { resolve } from 'node:path'; +import { + createScanner, + isArrowFunction, + isClassDeclaration, + isFunctionDeclaration, + isFunctionExpression, + isImportDeclaration, + isImportEqualsDeclaration, + isMethodDeclaration, + isPropertyDeclaration, + isVariableStatement, + SyntaxKind, +} from 'typescript/unstable/ast'; +import { API } from 'typescript/unstable/sync'; const lcovPath = process.argv[2] || 'coverage/lcov.info'; @@ -53,6 +71,30 @@ if (!existsSync(lcovPath)) { process.exit(0); } +// token kinds after which a '/' is a division operator, not the start of a +// regular expression literal; without parser context this is how the +// regex/division ambiguity is resolved when scanning +const NO_REGEX_AFTER = new Set([ + SyntaxKind.Identifier, + SyntaxKind.PrivateIdentifier, + SyntaxKind.NumericLiteral, + SyntaxKind.BigIntLiteral, + SyntaxKind.StringLiteral, + SyntaxKind.NoSubstitutionTemplateLiteral, + SyntaxKind.TemplateTail, + SyntaxKind.RegularExpressionLiteral, + SyntaxKind.ThisKeyword, + SyntaxKind.SuperKeyword, + SyntaxKind.TrueKeyword, + SyntaxKind.FalseKeyword, + SyntaxKind.NullKeyword, + SyntaxKind.CloseParenToken, + SyntaxKind.CloseBracketToken, + SyntaxKind.CloseBraceToken, + SyntaxKind.PlusPlusToken, + SyntaxKind.MinusMinusToken, +]); + /** * Returns the set of 1-based line numbers in the given source that hold no * executable code — i.e. blank lines and lines whose only content is a comment. @@ -62,39 +104,70 @@ if (!existsSync(lcovPath)) { */ function nonCodeLines(text) { const commentChars = new Uint8Array(text.length); - // Collect every comment range from the parsed tree. A standalone - // ts.createScanner is unreliable here — without parser context it can - // mis-scan a stray `/` as a regex literal and swallow the comments inside - // it — so walk all tokens and take their leading/trailing comment trivia, - // which every comment in the file belongs to. - const sf = ts.createSourceFile('x.ts', text, ts.ScriptTarget.Latest, true); - const seen = new Set(); - const mark = pos => { - for (const get of [ - ts.getLeadingCommentRanges, - ts.getTrailingCommentRanges, - ]) { - for (const range of get(text, pos) || []) { - const key = `${range.pos}:${range.end}`; - - if (seen.has(key)) { - continue; - } + // Scan with trivia emitted, so comments arrive as their own tokens. The + // scanner alone cannot tell a regex literal from a division nor a + // template-substitution '}' from a closing brace, so both are re-scanned + // explicitly — comment markers inside regexes and template literals are + // therefore not misdetected. + const scanner = createScanner(false, undefined, text); + const BRACE = 0; + const TEMPLATE = 1; + const stack = []; + let prev = SyntaxKind.Unknown; + let token; + + while ((token = scanner.scan()) !== SyntaxKind.EndOfFile) { + if ( + (token === SyntaxKind.SlashToken || + token === SyntaxKind.SlashEqualsToken) && + !NO_REGEX_AFTER.has(prev) + ) { + token = scanner.reScanSlashToken(); + } + + switch (token) { + case SyntaxKind.OpenBraceToken: + stack.push(BRACE); + break; + + case SyntaxKind.TemplateHead: + stack.push(TEMPLATE); + break; - seen.add(key); + case SyntaxKind.CloseBraceToken: + if (stack[stack.length - 1] === TEMPLATE) { + token = scanner.reScanTemplateToken(false); - for (let i = range.pos; i < range.end; i++) { + if (token === SyntaxKind.TemplateTail) { + stack.pop(); + } + } else { + stack.pop(); + } + break; + + case SyntaxKind.SingleLineCommentTrivia: + case SyntaxKind.MultiLineCommentTrivia: + for ( + let i = scanner.getTokenStart(); + i < scanner.getTokenEnd(); + i++ + ) { commentChars[i] = 1; } - } + break; } - }; - const visit = node => { - mark(node.getFullStart()); - node.getChildren(sf).forEach(visit); - }; - visit(sf); + // the regex heuristic needs the last meaningful token, not trivia + if ( + token !== SyntaxKind.SingleLineCommentTrivia && + token !== SyntaxKind.MultiLineCommentTrivia && + token !== SyntaxKind.WhitespaceTrivia && + token !== SyntaxKind.NewLineTrivia + ) { + prev = token; + } + } const lines = text.split('\n'); const result = new Set(); @@ -140,16 +213,10 @@ function nonCodeLines(text) { * Method/accessor/constructor bodies are never touched, so genuinely unreached * code still shows up. * - * @param {string} text + * @param {import('typescript/unstable/ast').SourceFile} sf * @returns {Set} */ -function importLines(text) { - const sf = ts.createSourceFile( - 'x.ts', - text, - ts.ScriptTarget.Latest, - false, - ); +function importLines(sf) { const result = new Set(); const lineAt = pos => sf.getLineAndCharacterOfPosition(pos).line; const addRange = (start, end) => { @@ -165,10 +232,10 @@ function importLines(text) { } if ( - ts.isFunctionExpression(n) || - ts.isArrowFunction(n) || - ts.isFunctionDeclaration(n) || - ts.isMethodDeclaration(n) + isFunctionExpression(n) || + isArrowFunction(n) || + isFunctionDeclaration(n) || + isMethodDeclaration(n) ) { found = true; @@ -184,18 +251,15 @@ function importLines(text) { }; for (const stmt of sf.statements) { - if ( - ts.isImportDeclaration(stmt) || - ts.isImportEqualsDeclaration(stmt) - ) { + if (isImportDeclaration(stmt) || isImportEqualsDeclaration(stmt)) { addRange(stmt.getStart(sf), stmt.getEnd()); - } else if (ts.isVariableStatement(stmt)) { + } else if (isVariableStatement(stmt)) { if (hasFunction(stmt)) { result.add(lineAt(stmt.getStart(sf)) + 1); } else { addRange(stmt.getStart(sf), stmt.getEnd()); } - } else if (ts.isClassDeclaration(stmt)) { + } else if (isClassDeclaration(stmt)) { // class header (`class X extends Y {`): everything up to the first // member, or the whole thing for an empty class const headerEnd = stmt.members.length @@ -208,10 +272,7 @@ function importLines(text) { // type-only fields (definite-assignment / uninitialized) emit no JS for (const member of stmt.members) { - if ( - ts.isPropertyDeclaration(member) && - !member.initializer - ) { + if (isPropertyDeclaration(member) && !member.initializer) { addRange(member.getStart(sf), member.getEnd()); } } @@ -221,6 +282,33 @@ function importLines(text) { return result; } +// The TypeScript 7 API server (spawned once, on first use) parses the +// project's sources; unlike TS 5/6 there is no in-process parser to feed +// file text into, but these are real project files, so the project-based +// API fits. Files outside the project resolve to no source file and only +// get comment/blank-line stripping. +let apiSession; + +/** + * Returns the parsed source file for a given path from the project's + * program, or undefined when unavailable. + * + * @param {string} sourceFile - path as recorded in the lcov file + * @returns {import('typescript/unstable/ast').SourceFile | undefined} + */ +function projectSourceFile(sourceFile) { + if (!apiSession) { + const api = new API({ cwd: process.cwd() }); + const snapshot = api.updateSnapshot({ + openProjects: [resolve('tsconfig.json')], + }); + + apiSession = { api, project: snapshot.getProjects()[0] }; + } + + return apiSession.project?.program.getSourceFile(resolve(sourceFile)); +} + const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const B64MAP = new Map([...B64].map((c, i) => [c, i])); @@ -339,8 +427,9 @@ function coverableInfo(sourceFile) { // drops type-only lines (interfaces, type aliases) which emit nothing; // the import check drops the mis-mapped import artifact. const text = readFileSync(sourceFile, 'utf8'); + const sf = projectSourceFile(sourceFile); const nonCode = nonCodeLines(text); - const imports = importLines(text); + const imports = sf ? importLines(sf) : new Set(); const keep = new Set( [...emitted].filter( line => !nonCode.has(line) && !imports.has(line), @@ -349,10 +438,13 @@ function coverableInfo(sourceFile) { info = { mode: 'keep', set: keep }; } else if (sourceFile && existsSync(sourceFile)) { const text = readFileSync(sourceFile, 'utf8'); + const sf = projectSourceFile(sourceFile); const skip = nonCodeLines(text); - for (const line of importLines(text)) { - skip.add(line); + if (sf) { + for (const line of importLines(sf)) { + skip.add(line); + } } info = { mode: 'drop', set: skip }; @@ -457,6 +549,7 @@ for (const line of readFileSync(lcovPath, 'utf8').split('\n')) { } } +apiSession?.api.close(); writeFileSync(lcovPath, output.join('')); console.info( `strip-comment-coverage: removed ${strippedLines} comment/blank line ` + diff --git a/src/IMQClient.ts b/src/IMQClient.ts index 095900e..0d98e10 100644 --- a/src/IMQClient.ts +++ b/src/IMQClient.ts @@ -50,15 +50,26 @@ import { writeFile, SIGNALS, } from './helpers'; -import { transpile, type CompilerOptions } from 'typescript'; import { EventEmitter } from 'node:events'; import { Script } from 'node:vm'; +import { spawnSync } from 'node:child_process'; +import { + mkdtempSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; import { IMQBeforeCall, IMQAfterCall } from './IMQRPCOptions'; // Loaded via require (not a static import) so that consumers which type-check // this source through a file: link do not need `resolveJsonModule` enabled. -const tsOptions = require('../tsconfig.json') - .compilerOptions as CompilerOptions; +const tsOptions = require('../tsconfig.json').compilerOptions as Record< + string, + unknown +>; const RX_SEMICOLON: RegExp = /;+$/g; /** @@ -754,6 +765,78 @@ function toComment(typedef: string, promised: boolean = false): string { .join('\n'); } +/** + * Transpiles generated client TypeScript source to CommonJS JavaScript. + * + * TypeScript 7 (native port) removed the in-process `transpile()`/`transpileModule()` + * API; its `typescript/unstable/*` replacement is not a stable runtime contract. + * Instead this shells out to the bundled `tsc` CLI in transform-only mode + * (`noCheck`, `noEmitOnError:false`) — the same "emit regardless of type or + * module-resolution errors" behaviour the old single-file transpile provided, + * built on the stable CLI rather than the unstable programmatic API. + * + * @param {string} src - generated client source + * @returns {string} - emitted CommonJS JavaScript + */ +function transpileClient(src: string): string { + const dir = mkdtempSync(join(tmpdir(), 'imq-client-')); + + try { + const tsFile = join(dir, '__client__.ts'); + const jsFile = join(dir, '__client__.js'); + const cfgFile = join(dir, 'tsconfig.json'); + + writeFileSync(tsFile, src); + writeFileSync( + cfgFile, + JSON.stringify({ + compilerOptions: { + ...tsOptions, + // transform-only: always emit, ignoring type and + // module-resolution errors (the generated client references + // `@imqueue/rpc` types that need not resolve in this temp + // dir) + noCheck: true, + noEmitOnError: false, + skipLibCheck: true, + declaration: false, + declarationMap: false, + sourceMap: false, + inlineSources: false, + types: [], + rootDir: dir, + outDir: dir, + }, + files: ['__client__.ts'], + }), + ); + + // resolve the compiler entry via the package root (its `exports` map + // blocks `typescript/lib/*`, so build a raw path that Node runs + // directly) + const tscJs = join( + dirname(require.resolve('typescript/package.json')), + 'lib', + 'tsc.js', + ); + const result = spawnSync(process.execPath, [tscJs, '-p', cfgFile], { + encoding: 'utf8', + }); + + if (!existsSync(jsFile)) { + throw new Error( + 'IMQClient: client transpilation produced no output' + + (result.stdout ? `\n${result.stdout}` : '') + + (result.stderr ? `\n${result.stderr}` : ''), + ); + } + + return readFileSync(jsFile, 'utf8'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + /** * Compiles client source code and returns loaded module * @@ -770,7 +853,7 @@ async function compile( const path = options.path; const srcFile = `${path}/${name}.ts`; const jsFile = `${path}/${name}.js`; - const js = transpile(src, tsOptions); + const js = transpileClient(src); if (options.write) { if (!(await fileExists(path))) { diff --git a/src/decorators/expose.ts b/src/decorators/expose.ts index b5cd9c1..8360ed9 100644 --- a/src/decorators/expose.ts +++ b/src/decorators/expose.ts @@ -21,16 +21,7 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import { - createSourceFile, - getLeadingCommentRanges, - isClassDeclaration, - isIdentifier, - isMethodDeclaration, - ScriptKind, - ScriptTarget, - SyntaxKind, -} from 'typescript'; +import { parse, type Comment, type Options } from 'acorn'; import { ArgDescription, ReturnValueDescription, IMQRPCDescription } from '..'; type CommentMetadata = { @@ -159,64 +150,97 @@ function parseComment(src: string): CommentMetadata { } /** - * Finds and parses methods and their comment blocks for a given class + * Finds and parses methods and their comment blocks for a given class. + * + * The class source (from `Function.prototype.toString`) is always compiled + * JavaScript, so it is parsed with acorn; a method's doc is the last block + * comment between the previous class member (or the class body start) and + * the method itself, i.e. the last block comment in its leading trivia. * * @param {string} name - class name * @param {string} src - class source code */ -function parseDescriptions(name: string, src: string) { - // parsed with the TypeScript compiler, which is already a runtime - // dependency (used for client generation) — no parent pointers needed, - // only class members and their leading comment trivia are read - const sourceFile = createSourceFile( - `${name}.js`, - src, - ScriptTarget.Latest, - false, - ScriptKind.JS, - ); +function parseDescriptions(name: string, src: string): void { + const comments: Comment[] = []; + const options: Options = { + // 'latest' so we can parse whatever modern syntax the configured + // compilation target emits into the class source we introspect + ecmaVersion: 'latest', + allowReserved: true, + onComment: comments, + }; // the local `inherits` is a placeholder only: the public description's // `inherits` is derived from the runtime prototype chain in - // buildMethodDescription(), never from the parsed source + // buildMethodDescription(), never from the parsed source (standard + // decorators compile the heritage clause to a helper like `_classSuper`, + // so the source name is unusable) descriptions[name] = { inherits: 'Function', }; - for (const statement of sourceFile.statements) { + let body: any[]; + + try { + body = (parse(src, options) as any).body; + } catch { + // tolerate unparseable sources (e.g. bound or native functions) the + // same way the previous error-tolerant parsers did + return; + } + + for (const node of body) { if ( - !isClassDeclaration(statement) || - !statement.name || - statement.name.text !== name + !( + node && + node.type === 'ClassDeclaration' && + node.id && + node.id.name === name && + node.body && + node.body.type === 'ClassBody' + ) ) { continue; } - for (const member of statement.members) { + const members: any[] = node.body.body; + + for (let i = 0; i < members.length; i++) { + const member = members[i]; + + // plain (non-accessor, non-constructor) methods with literal + // identifier names only — same filter the previous + // implementations applied if ( - !isMethodDeclaration(member) || - !member.name || - !isIdentifier(member.name) + member.type !== 'MethodDefinition' || + member.kind !== 'method' || + member.computed || + !member.key || + member.key.type !== 'Identifier' ) { continue; } - // the method's doc is the last block comment in its leading - // trivia; member.pos starts right after the previous token, so - // comments belonging to earlier members can never match - const blocks = ( - getLeadingCommentRanges(src, member.pos) || [] - ).filter(range => range.kind === SyntaxKind.MultiLineCommentTrivia); + // the doc block must lie after the previous member of any kind + // (so comments inside earlier bodies and field initializers can + // never match) and before the method itself + const lowerBound: number = i + ? members[i - 1].end + : node.body.start + 1; + const blocks = comments.filter( + comment => + comment.type === 'Block' && + comment.start >= lowerBound && + comment.end <= member.start, + ); if (!blocks.length) { continue; } - const block = blocks[blocks.length - 1]; - - descriptions[name][member.name.text] = { - // comment body without the enclosing /* and */ delimiters - comment: parseComment(src.slice(block.pos + 2, block.end - 2)), + descriptions[name][member.key.name] = { + // acorn comment values exclude the /* and */ delimiters + comment: parseComment(blocks[blocks.length - 1].value), }; } } diff --git a/test/IMQClient.generator.trailing.args.spec.ts b/test/IMQClient.generator.trailing.args.spec.ts index d88eb3d..a5e61fd 100644 --- a/test/IMQClient.generator.trailing.args.spec.ts +++ b/test/IMQClient.generator.trailing.args.spec.ts @@ -2,15 +2,13 @@ * IMQClient generator trailing args removal coverage test */ import * as fs from 'node:fs'; -import mockRequire from 'mock-require'; import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { IMQService, IMQClient, IMQDelay, IMQMetadata, expose } from '..'; -import * as imqRpc from '..'; -// The generated client imports from '@imqueue/rpc'; map it to this package so -// the compiled-and-required client resolves without a self node_modules link. -mockRequire('@imqueue/rpc', imqRpc); +// The generated client imports from '@imqueue/rpc'; the package.json `exports` +// field lets that specifier self-resolve to this in-tree build, so no module +// mock is needed to make the compiled-and-required client resolve. const CLIENTS_PATH = './test/clients-generator-trailing'; diff --git a/test/IMQClient.generator.types.spec.ts b/test/IMQClient.generator.types.spec.ts index 8253622..705e225 100644 --- a/test/IMQClient.generator.types.spec.ts +++ b/test/IMQClient.generator.types.spec.ts @@ -23,7 +23,6 @@ * to get commercial licensing options. */ import * as fs from 'node:fs'; -import mockRequire from 'mock-require'; import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { @@ -34,9 +33,10 @@ import { classType, indexed, } from '..'; -import * as imqRpc from '..'; -mockRequire('@imqueue/rpc', imqRpc); +// The generated client imports from '@imqueue/rpc'; the package.json `exports` +// field lets that specifier self-resolve to this in-tree build, so no module +// mock is needed to make the compiled client load. const CLIENTS_PATH = './test/clients-generator-types'; diff --git a/test/IMQClient.spec.ts b/test/IMQClient.spec.ts index d1973f9..92cd52b 100644 --- a/test/IMQClient.spec.ts +++ b/test/IMQClient.spec.ts @@ -21,7 +21,6 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import mockRequire from 'mock-require'; import { describe, it, before, after, mock } from 'node:test'; import assert from 'node:assert/strict'; import { logger } from './mocks'; @@ -34,9 +33,10 @@ import { remote, } from '..'; import * as fs from 'node:fs'; -import * as imqRpc from '..'; -mockRequire('@imqueue/rpc', imqRpc); +// The generated client imports from '@imqueue/rpc'; the package.json `exports` +// field lets that specifier self-resolve to this in-tree build, so no module +// mock is needed to make the compiled client load. class TestService extends IMQService { @expose() diff --git a/test/IMQClient.transpile.error.spec.ts b/test/IMQClient.transpile.error.spec.ts new file mode 100644 index 0000000..7425998 --- /dev/null +++ b/test/IMQClient.transpile.error.spec.ts @@ -0,0 +1,71 @@ +/*! + * IMQClient client-transpiler failure 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, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +// live module object so mock.method() patches the same spawnSync the client +// transpiler reads at call time +import childProcess = require('node:child_process'); +import { IMQService, IMQClient, expose } from '..'; + +// Own spec file (hence own process): generating a client makes exactly one +// getDescription() round-trip; a second create() in the same process would hang +// on the shared redis mock, so this failure path is isolated here. +class TranspileErrService extends IMQService { + @expose() + public ping() { + return 'pong'; + } +} + +describe('IMQClient client transpiler failure', () => { + let service: TranspileErrService; + + before(async () => { + service = new TranspileErrService(); + await service.start(); + }); + after(async () => { + await service.destroy(); + }); + + it('throws when the client transpiler produces no output', async t => { + // stub spawnSync so the tsc CLI never runs and emits no .js; the + // transpiler must detect the missing output and throw, echoing the + // captured stdout/stderr + t.mock.method(childProcess, 'spawnSync', (() => ({ + stdout: 'boom-out', + stderr: 'boom-err', + status: 1, + })) as any); + + await assert.rejects( + IMQClient.create('TranspileErrService', { + compile: false, + write: false, + }), + /client transpilation produced no output[\s\S]*boom-out[\s\S]*boom-err/, + ); + }); +}); diff --git a/test/IMQService.spec.ts b/test/IMQService.spec.ts index bc272c6..cf2e6b6 100644 --- a/test/IMQService.spec.ts +++ b/test/IMQService.spec.ts @@ -27,6 +27,10 @@ import assert from 'node:assert/strict'; import { IMQService, IMQRPCRequest, Description, expose } from '..'; import { randomUUID as uuid } from 'node:crypto'; +// require (not import): at runtime node:cluster's module.exports is the Cluster +// instance the code under test shares, but its module *type* omits those +// instance members (isMaster/listeners/…), so `any` via require avoids casting +// every mocked access const cluster: any = require('node:cluster'); class TestService extends IMQService { diff --git a/test/decorators/expose.spec.ts b/test/decorators/expose.spec.ts index 59ba63d..cbbdf5f 100644 --- a/test/decorators/expose.spec.ts +++ b/test/decorators/expose.spec.ts @@ -240,6 +240,42 @@ describe('decorators/expose()', () => { ); }); + it('should keep parsing after a regex literal following a keyword', () => { + class RegexBraceClass { + // `/\{+/` follows `return`, where a slash starts a regex, not a + // division; the unbalanced `{` inside must not corrupt the brace + // tracking that locates the next member + public tricky(v: string) { + return /\{+/.test(v); + } + + /** + * Documented after regex + * + * @return {string} - marker + */ + public documented() { + return 'ok'; + } + } + + const descriptor = Object.getOwnPropertyDescriptor( + RegexBraceClass.prototype, + 'documented', + ); + + expose()(RegexBraceClass.prototype, 'documented', descriptor); + + assert.equal( + description.RegexBraceClass.methods.documented.description, + 'Documented after regex', + ); + assert.equal( + description.RegexBraceClass.methods.documented.returns.tsType, + 'string', + ); + }); + it('should register the method via the legacy signature', () => { class LegacyExposeClass { /** @@ -272,4 +308,35 @@ describe('decorators/expose()', () => { 'Legacy documented method', ); }); + + it('should tolerate an unparseable class source', () => { + class UnparseableExposeClass { + public nativeLike() {} + } + + // a bound/native function's toString is not valid JavaScript + // (`[native code]` is a syntax error); the parser must swallow it and + // still register the method with default metadata + Object.defineProperty(UnparseableExposeClass, 'toString', { + value: () => 'function nativeLike() { [native code] }', + }); + + const descriptor = Object.getOwnPropertyDescriptor( + UnparseableExposeClass.prototype, + 'nativeLike', + ); + + assert.doesNotThrow(() => + expose()( + UnparseableExposeClass.prototype, + 'nativeLike', + descriptor, + ), + ); + assert.notEqual(description.UnparseableExposeClass, undefined); + assert.notEqual( + description.UnparseableExposeClass.methods.nativeLike, + undefined, + ); + }); }); diff --git a/test/helpers/fs.spec.ts b/test/helpers/fs.spec.ts index f3c92a6..bad4ad6 100644 --- a/test/helpers/fs.spec.ts +++ b/test/helpers/fs.spec.ts @@ -21,7 +21,7 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ -import mockRequire from 'mock-require'; +import { mockModule } from '../mocks'; import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; @@ -62,8 +62,10 @@ let mkdir: (path: string) => Promise; let writeFile: (path: string, content: string) => Promise; describe('fs helpers', () => { + let fsCtl: { restore(): void }; + before(function before() { - mockRequire('node:fs', fsMock); + fsCtl = mockModule('node:fs', fsMock); delete require.cache[require.resolve('../../src/helpers/fs')]; const fs = require('../../src/helpers/fs'); fileExists = fs.fileExists; @@ -71,7 +73,8 @@ describe('fs helpers', () => { writeFile = fs.writeFile; }); after(() => { - mockRequire.stop('node:fs'); + fsCtl.restore(); + delete require.cache[require.resolve('../../src/helpers/fs')]; }); it('should check file existance', () => diff --git a/test/helpers/os-uuid.spec.ts b/test/helpers/os-uuid.spec.ts index 08180a6..9d80d5a 100644 --- a/test/helpers/os-uuid.spec.ts +++ b/test/helpers/os-uuid.spec.ts @@ -27,8 +27,9 @@ import assert from 'node:assert/strict'; import { osUuid } from '../../src/helpers'; // The helper under test reads the live `require('node:child_process')` object at -// call time, so we mock that same object (not an `import * as` namespace copy). -const childProcess: typeof import('node:child_process') = require('node:child_process'); +// call time, so we mock that same object via import-equals (an `import * as` +// namespace copy under esModuleInterop would not be the object it reads). +import childProcess = require('node:child_process'); // osUuid memoizes its result at module level, so the per-platform parsing // tests below load a FRESH copy of the module after mocking the platform and diff --git a/test/mocks/index.ts b/test/mocks/index.ts index 723a20e..d14556f 100644 --- a/test/mocks/index.ts +++ b/test/mocks/index.ts @@ -23,3 +23,4 @@ */ export * from './logger'; export * from './redis'; +export * from './moduleMock'; diff --git a/test/mocks/moduleMock.ts b/test/mocks/moduleMock.ts new file mode 100644 index 0000000..3ef988f --- /dev/null +++ b/test/mocks/moduleMock.ts @@ -0,0 +1,77 @@ +/*! + * IMQ-RPC Unit Test Mocks: native module-mock helper + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { mock, MockModuleOptions } from 'node:test'; + +/** + * Builds `mock.module()` options for the running Node version. Node 24+ takes + * the mock's exports through the `exports` option (a `default` key is the + * default export; for CJS/builtin consumers `module.exports` becomes that + * value), and deprecated the earlier `defaultExport`/`namedExports` pair. + * Node 22 only understands the earlier pair, so the same exports shape is + * translated there. + * + * `cache: true` keeps mock-require's semantics: every `require()` of the + * mocked specifier returns the same module instance, so a test may patch a + * property in place and the code under test observes the change. + * + * @param {Record} exports - mock exports, `default` key being + * the default export + * @returns {MockModuleOptions} + */ +export function moduleMockOptions( + exports: Record, +): MockModuleOptions { + if (+process.versions.node.split('.')[0] >= 24) { + // typings (@types/node 24.x) lag the runtime's `exports` option + return { cache: true, exports } as MockModuleOptions; + } + + const { default: defaultExport, ...namedExports } = exports; + const options: MockModuleOptions = { cache: true }; + + if (defaultExport !== undefined) { + options.defaultExport = defaultExport; + } + + if (Object.keys(namedExports).length) { + options.namedExports = namedExports; + } + + return options; +} + +/** + * Registers a native `node:test` module mock whose CommonJS `module.exports` + * (and ESM default export) is `value`. Requires `--experimental-test-module-mocks`. + * + * @param {string} specifier - the module specifier to mock + * @param {unknown} value - the value `require(specifier)` should return + * @returns {{ restore(): void }} - handle to undo the mock + */ +export function mockModule( + specifier: string, + value: unknown, +): { restore(): void } { + return mock.module(specifier, moduleMockOptions({ default: value })); +} diff --git a/test/mocks/redis.ts b/test/mocks/redis.ts index 114f7bd..58a5ea8 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 mock from 'mock-require'; +import { mockModule } from './moduleMock'; import { EventEmitter } from 'node:events'; import * as crypto from 'node:crypto'; @@ -318,8 +318,11 @@ const Redis = RedisClientMock; // __esModule marks this as an ES-module shape so core's esModuleInterop // default import (`import Redis from 'ioredis'`) resolves to the constructor -// rather than the wrapper object -mock('ioredis', { __esModule: true, Redis, default: Redis }); +// rather than the wrapper object. Native module mocking cannot merge named +// exports onto a class default, so the whole ESM-shaped namespace object is +// registered as the module's export (for CJS consumers it IS what +// `require('ioredis')` returns). +mockModule('ioredis', { __esModule: true, Redis, default: Redis }); // @ts-ignore export * from 'ioredis'; diff --git a/typedoc.json b/typedoc.json deleted file mode 100644 index b8af015..0000000 --- a/typedoc.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://typedoc.org/schema.json", - "name": "@imqueue/rpc", - "entryPoints": ["."], - "entryPointStrategy": "expand", - "exclude": [ - "**/+(debug|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" -}