├── .dockerignore ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── build.ts ├── bun.lock ├── dist ├── cjs │ └── index.cjs ├── esm │ └── index.js └── types │ ├── camelcase.d.ts │ ├── index.d.ts │ └── types.d.ts ├── docker-compose.yml ├── eslint.config.mjs ├── package.json ├── src ├── camelcase.ts ├── index.test.ts ├── index.ts └── types.ts ├── tsconfig.build.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | 5 | jobs: 6 | test: 7 | concurrency: 8 | group: test-${{ github.ref }} 9 | cancel-in-progress: true 10 | runs-on: ubuntu-latest 11 | services: 12 | beanstalkd: 13 | image: ghcr.io/beanstalkd/beanstalkd:latest 14 | ports: 15 | - 11300:11300 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: oven-sh/setup-bun@v1 20 | with: 21 | bun-version: latest 22 | 23 | - run: bun install 24 | - run: bun test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | .env.test 60 | 61 | # parcel-bundler cache (https://parceljs.org/) 62 | .cache 63 | 64 | # next.js build output 65 | .next 66 | 67 | # nuxt.js build output 68 | .nuxt 69 | 70 | # vuepress build output 71 | .vuepress/dist 72 | 73 | # Serverless directories 74 | .serverless/ 75 | 76 | # FuseBox cache 77 | .fusebox/ 78 | 79 | # DynamoDB Local files 80 | .dynamodb/ 81 | 82 | .env.local 83 | audit 84 | tmp 85 | 86 | changes.diff -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .circleci 2 | .dockerignore 3 | docker-compose.yml 4 | .env.local 5 | audit 6 | tmp 7 | .github 8 | node_modules 9 | bun.lock -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": false, 4 | "trailingComma": "none", 5 | "bracketSpacing": true, 6 | "jsxBracketSameLine": false, 7 | "semi": false, 8 | "requirePragma": false, 9 | "proseWrap": "preserve", 10 | "arrowParens": "avoid" 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "always" 6 | }, 7 | "eslint.enable": true 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019-2022 Dexter Miguel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jackd 2 | 3 | ![Tests](https://github.com/divmgl/jackd/actions/workflows/test.yml/badge.svg) 4 | 5 | Modern beanstalkd client for Node/Bun 6 | 7 | ## Quick start 8 | 9 | ```ts 10 | import Jackd from "jackd" 11 | 12 | const client = new Jackd() 13 | 14 | // Publishing a job 15 | await client.put({ greeting: "Hello!" }) 16 | 17 | // Consuming a job 18 | const job = await client.reserve() // => { id: '1', payload: '{"greeting":"Hello!"}' } 19 | 20 | // Process the job, then delete it 21 | await client.delete(job.id) 22 | ``` 23 | 24 | ## Installation 25 | 26 | ```bash 27 | npm install jackd 28 | yarn add jackd 29 | pnpm add jackd 30 | bun add jackd 31 | ``` 32 | 33 | ## Why 34 | 35 | Beanstalkd is a simple and blazing fast work queue. It's a great tool for building background job runners, pub/sub systems, and more. 36 | 37 | Jackd is a modern Node/Bun client for Beanstalkd written in TypeScript. It has: 38 | 39 | - A concise and easy to use API 40 | - Full type safety 41 | - Native `Promise` support 42 | - A single dependency: `yaml` 43 | - Protocol accuracy/completeness 44 | 45 | If you don't have experience using Beanstalkd, it's a good idea to read [the Beanstalkd protocol](https://github.com/beanstalkd/beanstalkd/blob/master/doc/protocol.txt) before using this library. 46 | 47 | ## Documentation 48 | 49 | ### Putting Jobs 50 | 51 | You can add jobs to Beanstalkd by using the `put` command, which accepts a payload and returns a job ID. 52 | 53 | ```ts 54 | const jobId: number = await client.put({ foo: "bar" }) 55 | console.log(jobId) // => 1 56 | ``` 57 | 58 | Job payloads are byte arrays. Passing in a `Uint8Array` will send the payload as-is. 59 | 60 | ```ts 61 | const jobId = await client.put([123, 123, 123]) 62 | ``` 63 | 64 | You can also pass in a `String` or an `Object` and `jackd` will automatically convert these values into byte arrays. 65 | 66 | ```ts 67 | const jobId = await client.put("my long running job") // TextEncoder.encode(string) 68 | const jobId = await client.put({ foo: "bar" }) // TextEncoder.encode(JSON.stringify(object)) 69 | ``` 70 | 71 | All jobs sent to beanstalkd have a priority, a delay, and TTR (time-to-run) specification. By default, all jobs are published with `0` priority, `0` delay, and `60` TTR, which means consumers will have 60 seconds to finish the job after reservation. You can override these defaults: 72 | 73 | ```ts 74 | await client.put( 75 | { foo: "bar" }, 76 | { 77 | delay: 2, // Two second delay 78 | priority: 10, 79 | ttr: 600 // Ten minute delay 80 | } 81 | ) 82 | ``` 83 | 84 | Jobs with lower priorities are handled first. Refer to [the protocol specs](https://github.com/beanstalkd/beanstalkd/blob/master/doc/protocol.txt#L126) for more information on job options. 85 | 86 | ### Reserving Jobs 87 | 88 | You can receive jobs by using the `reserve` command: 89 | 90 | ```ts 91 | // Get a job with string payload 92 | const { id, payload } = await client.reserve() 93 | console.log({ id, payload }) // => { id: '1', payload: 'Hello!' } 94 | 95 | // Get a job with raw Uint8Array payload 96 | const { id, payload } = await client.reserveRaw() 97 | console.log({ id, payload }) // => { id: '1', payload: Uint8Array } 98 | ``` 99 | 100 | Job reservation is how beanstalkd implements work distribution. Once you've reserved a job, you can process it and then delete it: 101 | 102 | ```ts 103 | const { id, payload } = await client.reserve() 104 | // Do some long-running operation 105 | await client.delete(id) 106 | ``` 107 | 108 | If you don't handle the job within 60s (which is the default TTR), the job will be released back into the queue. 109 | 110 | If you passed in an object when putting the job, you'll need to parse the JSON string: 111 | 112 | ```js 113 | const { id, payload } = await client.reserve() 114 | const object = JSON.parse(payload) 115 | ``` 116 | 117 | Please keep in mind that reservation is a _blocking_ operation. This means that your script will stop executing until a job has been reserved. 118 | 119 | ### Tubes 120 | 121 | Beanstalkd queues are called tubes. Clients send to and reserve jobs from tubes. 122 | 123 | Clients keep a watchlist, which determines which tubes they'll reserve jobs from. By default, all clients "watch" the `default` tube. You can watch a new tube by using the `watch` command. 124 | 125 | ```ts 126 | // Watch both the "default" and "awesome-tube" tubes 127 | const numberOfTubesWatched = await client.watch("awesome-tube") 128 | console.log(numberOfTubesWatched) // => 2 129 | ``` 130 | 131 | You can also ignore a tube by using the `ignore` command. 132 | 133 | ```ts 134 | // Ignore the "default" tube so we'll only watch "awesome-tube" 135 | const numberOfTubesWatched = await client.ignore("default") 136 | console.log(numberOfTubesWatched) // => 1 137 | ``` 138 | 139 | > Note: attempting to ignore the only tube being watched will throw an exception. 140 | 141 | While clients can watch more than one tube at once, they can only publish jobs to the tube they're currently "using". Clients by default use the `default` tube. 142 | 143 | You can change the tube you're using with the `use` command. 144 | 145 | ```ts 146 | const tubeName = await client.use("awesome-tube") 147 | console.log(tubeName) // => 'awesome-tube' 148 | 149 | await client.put({ foo: "bar" }) // This job will be published to "awesome-tube" rather than "default" 150 | ``` 151 | 152 | ### Job management 153 | 154 | The most common operation after processing a job is deleting it: 155 | 156 | ```ts 157 | await client.delete(id) 158 | ``` 159 | 160 | However, there are other things you can do with a job. For instance, you can release it back into the queue if you can't process it right now: 161 | 162 | ```ts 163 | // Release immediately with high priority (0) and no delay (0) 164 | await client.release(id) 165 | 166 | // You can also specify the priority and the delay 167 | await client.release(id, { priority: 10, delay: 10 }) 168 | ``` 169 | 170 | Sometimes a job can't be processed, for whatever reason. A common example of this is when a job continues to fail over and over. 171 | 172 | You can bury the job so it can be processed again later: 173 | 174 | ```ts 175 | await client.bury(id) 176 | // ... some time later ... 177 | await client.kickJob(id) 178 | ``` 179 | 180 | The `kickJob` command is a convenience method for kicking a specific job on the currently used tube into the ready queue. 181 | 182 | You can kick multiple buried jobs at once: 183 | 184 | ```ts 185 | await client.kick(10) // 10 buried jobs will be moved to a ready state 186 | ``` 187 | 188 | Sometimes a job is taking too long to process, but you're making progress. You can extend the time you have to process a job by touching it: 189 | 190 | ```ts 191 | await client.touch(id) 192 | ``` 193 | 194 | This lets Beanstalkd know that you're still working on the job. 195 | 196 | ### Statistics 197 | 198 | Beanstalkd has a number of commands that returns statistics. 199 | 200 | For instance, the `stats` command returns details regarding the current Beanstalkd instance: 201 | 202 | ```js 203 | const stats = await client.stats() 204 | console.log(stats) 205 | /* => 206 | { 207 | currentJobsUrgent: 0, 208 | currentJobsReady: 0, 209 | currentJobsReserved: 0, 210 | currentJobsDelayed: 0, 211 | currentJobsBuried: 0, 212 | ... 213 | } 214 | */ 215 | ``` 216 | 217 | You can also get statistics for a specific tube: 218 | 219 | ```ts 220 | const stats = await client.statsTube("awesome-tube") 221 | console.log(stats) 222 | /* => 223 | { 224 | name: "awesome-tube", 225 | currentJobsUrgent: 0, 226 | currentJobsReady: 0, 227 | currentJobsReserved: 0, 228 | currentJobsDelayed: 0, 229 | currentJobsBuried: 0, 230 | ... 231 | } 232 | */ 233 | ``` 234 | 235 | Or statistics for a specific job: 236 | 237 | ```ts 238 | const stats = await client.statsJob(id) 239 | console.log(stats) 240 | /* => 241 | { 242 | id: "1", 243 | tube: "awesome-tube", 244 | state: "ready", 245 | ... 246 | } 247 | */ 248 | ``` 249 | 250 | ### Workers 251 | 252 | You may be looking to design a process that does nothing else but consume jobs. Here's an example implementation. 253 | 254 | ```ts 255 | /* consumer.ts */ 256 | import Jackd from "jackd" 257 | 258 | const client = new Jackd() 259 | 260 | void start() 261 | 262 | async function start() { 263 | while (true) { 264 | try { 265 | const { id, payload } = await client.reserve() 266 | /* ... process job here ... */ 267 | await client.delete(id) 268 | } catch (err) { 269 | // Capture error somehow 270 | console.error(err) 271 | } 272 | } 273 | 274 | process.exit(0) 275 | } 276 | ``` 277 | 278 | This process will run indefinitely, consuming jobs and processing them. 279 | 280 | ## License 281 | 282 | MIT 283 | -------------------------------------------------------------------------------- /build.ts: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild" 2 | import { rename } from "fs/promises" 3 | 4 | // Build bundles 5 | await Promise.all([ 6 | esbuild.build({ 7 | entryPoints: ["src/index.ts"], 8 | bundle: true, 9 | format: "esm", 10 | outfile: "dist/esm/index.js", 11 | target: "node18", 12 | platform: "node" 13 | }), 14 | esbuild.build({ 15 | entryPoints: ["src/index.ts"], 16 | bundle: true, 17 | format: "cjs", 18 | outfile: "dist/cjs/index.js", 19 | target: "node18", 20 | platform: "node" 21 | }) 22 | ]) 23 | 24 | // Rename the CommonJS output to .cjs 25 | await rename("dist/cjs/index.js", "dist/cjs/index.cjs") 26 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "jackd", 6 | "dependencies": { 7 | "yaml": "2.7.0", 8 | }, 9 | "devDependencies": { 10 | "@eslint/js": "9.18.0", 11 | "@types/node": "22.10.7", 12 | "bun-types": "^1.2.2", 13 | "esbuild": "0.25.0", 14 | "eslint": "9.18.0", 15 | "eslint-plugin-unused-imports": "4.1.4", 16 | "typescript": "5.7.3", 17 | "typescript-eslint": "8.20.0", 18 | }, 19 | }, 20 | }, 21 | "packages": { 22 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="], 23 | 24 | "@esbuild/android-arm": ["@esbuild/android-arm@0.25.0", "", { "os": "android", "cpu": "arm" }, "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g=="], 25 | 26 | "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.0", "", { "os": "android", "cpu": "arm64" }, "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g=="], 27 | 28 | "@esbuild/android-x64": ["@esbuild/android-x64@0.25.0", "", { "os": "android", "cpu": "x64" }, "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg=="], 29 | 30 | "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw=="], 31 | 32 | "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg=="], 33 | 34 | "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w=="], 35 | 36 | "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A=="], 37 | 38 | "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.0", "", { "os": "linux", "cpu": "arm" }, "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg=="], 39 | 40 | "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg=="], 41 | 42 | "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg=="], 43 | 44 | "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw=="], 45 | 46 | "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ=="], 47 | 48 | "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw=="], 49 | 50 | "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA=="], 51 | 52 | "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA=="], 53 | 54 | "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw=="], 55 | 56 | "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.0", "", { "os": "none", "cpu": "arm64" }, "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw=="], 57 | 58 | "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.0", "", { "os": "none", "cpu": "x64" }, "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA=="], 59 | 60 | "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw=="], 61 | 62 | "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg=="], 63 | 64 | "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg=="], 65 | 66 | "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw=="], 67 | 68 | "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA=="], 69 | 70 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ=="], 71 | 72 | "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.4.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA=="], 73 | 74 | "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], 75 | 76 | "@eslint/config-array": ["@eslint/config-array@0.19.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.5", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA=="], 77 | 78 | "@eslint/core": ["@eslint/core@0.10.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw=="], 79 | 80 | "@eslint/eslintrc": ["@eslint/eslintrc@3.2.0", "", { "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" } }, "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w=="], 81 | 82 | "@eslint/js": ["@eslint/js@9.18.0", "", {}, "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA=="], 83 | 84 | "@eslint/object-schema": ["@eslint/object-schema@2.1.5", "", {}, "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ=="], 85 | 86 | "@eslint/plugin-kit": ["@eslint/plugin-kit@0.2.5", "", { "dependencies": { "@eslint/core": "^0.10.0", "levn": "^0.4.1" } }, "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A=="], 87 | 88 | "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], 89 | 90 | "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], 91 | 92 | "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], 93 | 94 | "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.1", "", {}, "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA=="], 95 | 96 | "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], 97 | 98 | "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], 99 | 100 | "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], 101 | 102 | "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], 103 | 104 | "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], 105 | 106 | "@types/node": ["@types/node@22.10.7", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg=="], 107 | 108 | "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], 109 | 110 | "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.20.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.20.0", "@typescript-eslint/type-utils": "8.20.0", "@typescript-eslint/utils": "8.20.0", "@typescript-eslint/visitor-keys": "8.20.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.8.0" } }, "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A=="], 111 | 112 | "@typescript-eslint/parser": ["@typescript-eslint/parser@8.20.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.20.0", "@typescript-eslint/types": "8.20.0", "@typescript-eslint/typescript-estree": "8.20.0", "@typescript-eslint/visitor-keys": "8.20.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.8.0" } }, "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g=="], 113 | 114 | "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.20.0", "", { "dependencies": { "@typescript-eslint/types": "8.20.0", "@typescript-eslint/visitor-keys": "8.20.0" } }, "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw=="], 115 | 116 | "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.20.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.20.0", "@typescript-eslint/utils": "8.20.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.8.0" } }, "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA=="], 117 | 118 | "@typescript-eslint/types": ["@typescript-eslint/types@8.20.0", "", {}, "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA=="], 119 | 120 | "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.20.0", "", { "dependencies": { "@typescript-eslint/types": "8.20.0", "@typescript-eslint/visitor-keys": "8.20.0", "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.0.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.8.0" } }, "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA=="], 121 | 122 | "@typescript-eslint/utils": ["@typescript-eslint/utils@8.20.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.20.0", "@typescript-eslint/types": "8.20.0", "@typescript-eslint/typescript-estree": "8.20.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.8.0" } }, "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA=="], 123 | 124 | "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.20.0", "", { "dependencies": { "@typescript-eslint/types": "8.20.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA=="], 125 | 126 | "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], 127 | 128 | "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], 129 | 130 | "ajv": ["ajv@6.12.6", "", { "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" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], 131 | 132 | "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 133 | 134 | "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 135 | 136 | "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], 137 | 138 | "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], 139 | 140 | "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], 141 | 142 | "bun-types": ["bun-types@1.2.2", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-RCbMH5elr9gjgDGDhkTTugA21XtJAy/9jkKe/G3WR2q17VPGhcquf9Sir6uay9iW+7P/BV0CAHA1XlHXMAVKHg=="], 143 | 144 | "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], 145 | 146 | "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 147 | 148 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 149 | 150 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 151 | 152 | "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], 153 | 154 | "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 155 | 156 | "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 157 | 158 | "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], 159 | 160 | "esbuild": ["esbuild@0.25.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.0", "@esbuild/android-arm": "0.25.0", "@esbuild/android-arm64": "0.25.0", "@esbuild/android-x64": "0.25.0", "@esbuild/darwin-arm64": "0.25.0", "@esbuild/darwin-x64": "0.25.0", "@esbuild/freebsd-arm64": "0.25.0", "@esbuild/freebsd-x64": "0.25.0", "@esbuild/linux-arm": "0.25.0", "@esbuild/linux-arm64": "0.25.0", "@esbuild/linux-ia32": "0.25.0", "@esbuild/linux-loong64": "0.25.0", "@esbuild/linux-mips64el": "0.25.0", "@esbuild/linux-ppc64": "0.25.0", "@esbuild/linux-riscv64": "0.25.0", "@esbuild/linux-s390x": "0.25.0", "@esbuild/linux-x64": "0.25.0", "@esbuild/netbsd-arm64": "0.25.0", "@esbuild/netbsd-x64": "0.25.0", "@esbuild/openbsd-arm64": "0.25.0", "@esbuild/openbsd-x64": "0.25.0", "@esbuild/sunos-x64": "0.25.0", "@esbuild/win32-arm64": "0.25.0", "@esbuild/win32-ia32": "0.25.0", "@esbuild/win32-x64": "0.25.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw=="], 161 | 162 | "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], 163 | 164 | "eslint": ["eslint@9.18.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.10.0", "@eslint/eslintrc": "^3.2.0", "@eslint/js": "9.18.0", "@eslint/plugin-kit": "^0.2.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.1", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "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.2.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.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" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA=="], 165 | 166 | "eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.1.4", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ=="], 167 | 168 | "eslint-scope": ["eslint-scope@8.2.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A=="], 169 | 170 | "eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="], 171 | 172 | "espree": ["espree@10.3.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.0" } }, "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg=="], 173 | 174 | "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], 175 | 176 | "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], 177 | 178 | "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], 179 | 180 | "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], 181 | 182 | "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], 183 | 184 | "fast-glob": ["fast-glob@3.3.3", "", { "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" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], 185 | 186 | "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], 187 | 188 | "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], 189 | 190 | "fastq": ["fastq@1.18.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw=="], 191 | 192 | "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], 193 | 194 | "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], 195 | 196 | "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], 197 | 198 | "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], 199 | 200 | "flatted": ["flatted@3.3.2", "", {}, "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA=="], 201 | 202 | "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], 203 | 204 | "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], 205 | 206 | "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], 207 | 208 | "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], 209 | 210 | "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], 211 | 212 | "import-fresh": ["import-fresh@3.3.0", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw=="], 213 | 214 | "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], 215 | 216 | "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 217 | 218 | "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 219 | 220 | "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], 221 | 222 | "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 223 | 224 | "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], 225 | 226 | "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], 227 | 228 | "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], 229 | 230 | "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], 231 | 232 | "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], 233 | 234 | "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], 235 | 236 | "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], 237 | 238 | "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], 239 | 240 | "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], 241 | 242 | "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], 243 | 244 | "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], 245 | 246 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 247 | 248 | "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], 249 | 250 | "optionator": ["optionator@0.9.4", "", { "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" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], 251 | 252 | "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], 253 | 254 | "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], 255 | 256 | "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], 257 | 258 | "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], 259 | 260 | "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 261 | 262 | "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 263 | 264 | "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], 265 | 266 | "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 267 | 268 | "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], 269 | 270 | "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], 271 | 272 | "reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="], 273 | 274 | "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], 275 | 276 | "semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], 277 | 278 | "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], 279 | 280 | "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 281 | 282 | "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], 283 | 284 | "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 285 | 286 | "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 287 | 288 | "ts-api-utils": ["ts-api-utils@2.0.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ=="], 289 | 290 | "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], 291 | 292 | "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], 293 | 294 | "typescript-eslint": ["typescript-eslint@8.20.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.20.0", "@typescript-eslint/parser": "8.20.0", "@typescript-eslint/utils": "8.20.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.8.0" } }, "sha512-Kxz2QRFsgbWj6Xcftlw3Dd154b3cEPFqQC+qMZrMypSijPd4UanKKvoKDrJ4o8AIfZFKAF+7sMaEIR8mTElozA=="], 295 | 296 | "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], 297 | 298 | "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], 299 | 300 | "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 301 | 302 | "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], 303 | 304 | "yaml": ["yaml@2.7.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA=="], 305 | 306 | "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 307 | 308 | "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], 309 | 310 | "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], 311 | 312 | "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 313 | 314 | "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], 315 | 316 | "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /dist/types/camelcase.d.ts: -------------------------------------------------------------------------------- 1 | interface CamelCaseOptions { 2 | pascalCase?: boolean; 3 | preserveConsecutiveUppercase?: boolean; 4 | locale?: string | false; 5 | } 6 | export default function camelCase(input: string | string[], options?: CamelCaseOptions): string; 7 | export {}; 8 | -------------------------------------------------------------------------------- /dist/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "net"; 2 | import { type JackdProps } from "./types"; 3 | import type { CommandHandler, JackdArgs, JackdJob, JackdJobRaw, JackdPutOpts, JobStats, SystemStats, TubeStats } from "./types"; 4 | /** 5 | * Beanstalkd client 6 | * 7 | * ```ts 8 | * import Jackd from "jackd" 9 | * 10 | * const client = new Jackd() 11 | * 12 | * await client.put("Hello!") 13 | * 14 | * // At a later time 15 | * const { id, payload } = await client.reserve() 16 | * console.log({ id, payload }) // => { id: '1', payload: 'Hello!' } 17 | * 18 | * // Process the job, then delete it 19 | * await client.delete(id) 20 | * ``` 21 | */ 22 | export declare class JackdClient { 23 | socket: Socket; 24 | connected: boolean; 25 | private chunkLength; 26 | private host; 27 | private port; 28 | private autoReconnect; 29 | private initialReconnectDelay; 30 | private maxReconnectDelay; 31 | private maxReconnectAttempts; 32 | private reconnectAttempts; 33 | private currentReconnectDelay; 34 | private reconnectTimeout?; 35 | private isReconnecting; 36 | private watchedTubes; 37 | private currentTube; 38 | private executions; 39 | private buffer; 40 | private commandBuffer; 41 | private isProcessing; 42 | constructor({ autoconnect, host, port, autoReconnect, initialReconnectDelay, maxReconnectDelay, maxReconnectAttempts }?: JackdProps); 43 | private createSocket; 44 | private handleDisconnect; 45 | private attemptReconnect; 46 | /** 47 | * For environments where network partitioning is common. 48 | * @returns {Boolean} 49 | */ 50 | isConnected(): boolean; 51 | connect(): Promise; 52 | /** 53 | * Rewatches all previously watched tubes after a reconnection 54 | * If default is not in the watched tubes list, ignores it 55 | */ 56 | private rewatchTubes; 57 | /** 58 | * Reuses the previously used tube after a reconnection 59 | */ 60 | private reuseTube; 61 | quit: () => Promise; 62 | close: () => Promise; 63 | disconnect: () => Promise; 64 | createCommandHandler(commandStringFunction: (...args: TArgs) => Uint8Array, handlers: CommandHandler[]): (...args: TArgs) => Promise; 65 | private processNextCommand; 66 | private write; 67 | /** 68 | * Puts a job into the currently used tube 69 | * @param payload Job data - will be JSON stringified if object 70 | * @param options Priority, delay and TTR options 71 | * @returns Job ID 72 | * @throws {Error} BURIED if server out of memory 73 | * @throws {Error} EXPECTED_CRLF if job body not properly terminated 74 | * @throws {Error} JOB_TOO_BIG if job larger than max-job-size 75 | * @throws {Error} DRAINING if server in drain mode 76 | */ 77 | put: (payload: string | object | Uint8Array, options?: JackdPutOpts | undefined) => Promise; 78 | /** 79 | * Changes the tube used for subsequent put commands 80 | * @param tube Tube name (max 200 bytes). Created if doesn't exist. 81 | * @returns Name of tube now being used 82 | */ 83 | use: (tubeId: string) => Promise; 84 | createReserveHandlers(additionalResponses?: Array, decodePayload?: boolean): [CommandHandler, CommandHandler]; 85 | /** 86 | * Reserves a job from any watched tube 87 | * @returns Reserved job with string payload 88 | * @throws {Error} DEADLINE_SOON if reserved job TTR expiring 89 | * @throws {Error} TIMED_OUT if timeout exceeded with no job 90 | */ 91 | reserve: () => Promise; 92 | /** 93 | * Reserves a job with raw byte payload 94 | * @returns Reserved job with raw payload 95 | * @throws {Error} DEADLINE_SOON if reserved job TTR expiring 96 | * @throws {Error} TIMED_OUT if timeout exceeded with no job 97 | */ 98 | reserveRaw: () => Promise; 99 | /** 100 | * Reserves a job with timeout 101 | * @param seconds Max seconds to wait. 0 returns immediately. 102 | * @returns Reserved job 103 | * @throws {Error} DEADLINE_SOON if reserved job TTR expiring 104 | * @throws {Error} TIMED_OUT if timeout exceeded with no job 105 | */ 106 | reserveWithTimeout: (args_0: number) => Promise; 107 | /** 108 | * Reserves a specific job by ID 109 | * @param id Job ID to reserve 110 | * @returns Reserved job 111 | * @throws {Error} NOT_FOUND if job doesn't exist or not reservable 112 | */ 113 | reserveJob: (args_0: number) => Promise; 114 | /** 115 | * Deletes a job 116 | * @param id Job ID to delete 117 | * @throws {Error} NOT_FOUND if job doesn't exist or not deletable 118 | */ 119 | delete: (jobId: number) => Promise; 120 | /** 121 | * Releases a reserved job back to ready queue 122 | * @param id Job ID to release 123 | * @param options New priority and delay 124 | * @throws {Error} BURIED if server out of memory 125 | * @throws {Error} NOT_FOUND if job doesn't exist or not reserved by this client 126 | */ 127 | release: (jobId: number, options?: import("./types").JackdReleaseOpts | undefined) => Promise; 128 | /** 129 | * Buries a job 130 | * @param id Job ID to bury 131 | * @param priority New priority 132 | * @throws {Error} NOT_FOUND if job doesn't exist or not reserved by this client 133 | */ 134 | bury: (jobId: number, priority?: number | undefined) => Promise; 135 | /** 136 | * Touches a reserved job, requesting more time to work on it 137 | * @param id Job ID to touch 138 | * @throws {Error} NOT_FOUND if job doesn't exist or not reserved by this client 139 | */ 140 | touch: (jobId: number) => Promise; 141 | /** 142 | * Adds tube to watch list for reserve commands 143 | * @param tube Tube name to watch (max 200 bytes) 144 | * @returns Number of tubes now being watched 145 | */ 146 | watch: (tubeId: string) => Promise; 147 | /** 148 | * Removes tube from watch list 149 | * @param tube Tube name to ignore 150 | * @returns Number of tubes now being watched 151 | * @throws {Error} NOT_IGNORED if trying to ignore only watched tube 152 | */ 153 | ignore: (tubeId: string) => Promise; 154 | /** 155 | * Pauses new job reservations in a tube 156 | * @param tube Tube name to pause 157 | * @param delay Seconds to pause for 158 | * @throws {Error} NOT_FOUND if tube doesn't exist 159 | */ 160 | pauseTube: (tubeId: string, options?: import("./types").JackdPauseTubeOpts | undefined) => Promise; 161 | /** 162 | * Peeks at a specific job 163 | * @param id Job ID to peek at 164 | * @returns Job data if found 165 | * @throws {Error} NOT_FOUND if job doesn't exist 166 | */ 167 | peek: (jobId: number) => Promise; 168 | createPeekHandlers(): [CommandHandler, CommandHandler]; 169 | /** 170 | * Peeks at the next ready job in the currently used tube 171 | * @returns Job data if found 172 | * @throws {Error} NOT_FOUND if no ready jobs 173 | */ 174 | peekReady: () => Promise; 175 | /** 176 | * Peeks at the delayed job with shortest delay in currently used tube 177 | * @returns Job data if found 178 | * @throws {Error} NOT_FOUND if no delayed jobs 179 | */ 180 | peekDelayed: () => Promise; 181 | /** 182 | * Peeks at the next buried job in currently used tube 183 | * @returns Job data if found 184 | * @throws {Error} NOT_FOUND if no buried jobs 185 | */ 186 | peekBuried: () => Promise; 187 | /** 188 | * Kicks at most bound jobs from buried to ready queue in currently used tube 189 | * @param bound Maximum number of jobs to kick 190 | * @returns Number of jobs actually kicked 191 | */ 192 | kick: (jobsCount: number) => Promise; 193 | /** 194 | * Kicks a specific buried or delayed job into ready queue 195 | * @param id Job ID to kick 196 | * @throws {Error} NOT_FOUND if job doesn't exist or not in kickable state 197 | */ 198 | kickJob: (jobId: number) => Promise; 199 | /** 200 | * Gets statistical information about a job 201 | * @param id Job ID 202 | * @returns Job statistics 203 | * @throws {Error} NOT_FOUND if job doesn't exist 204 | */ 205 | statsJob: (jobId: number) => Promise; 206 | /** 207 | * Gets statistical information about a tube 208 | * @param tube Tube name 209 | * @returns Tube statistics 210 | * @throws {Error} NOT_FOUND if tube doesn't exist 211 | */ 212 | statsTube: (tubeId: string) => Promise; 213 | /** 214 | * Gets statistical information about the system 215 | * @returns System statistics 216 | */ 217 | stats: () => Promise; 218 | /** 219 | * Lists all existing tubes 220 | * @returns Array of tube names 221 | */ 222 | listTubes: () => Promise; 223 | /** 224 | * Lists tubes being watched by current connection 225 | * @returns Array of watched tube names 226 | */ 227 | listTubesWatched: () => Promise; 228 | /** 229 | * Returns the tube currently being used by client 230 | * @returns Name of tube being used 231 | */ 232 | listTubeUsed: () => Promise; 233 | } 234 | export default JackdClient; 235 | export { JackdError, JackdErrorCode } from "./types"; 236 | -------------------------------------------------------------------------------- /dist/types/types.d.ts: -------------------------------------------------------------------------------- 1 | import type EventEmitter from "events"; 2 | export declare const DELIMITER = "\r\n"; 3 | export type JackdPayload = Uint8Array | string | object; 4 | /** 5 | * Handler for processing command responses 6 | */ 7 | export type CommandHandler = (chunk: Uint8Array, command: Uint8Array) => T | Promise; 8 | /** 9 | * Command execution state 10 | */ 11 | export type CommandExecution = { 12 | command: Uint8Array; 13 | /** Handlers for processing command response */ 14 | handlers: CommandHandler[]; 15 | emitter: EventEmitter; 16 | written: boolean; 17 | }; 18 | /** 19 | * Options for putting a job into a tube 20 | */ 21 | export interface JackdPutOpts { 22 | /** Priority value between 0 and 2**32. Jobs with smaller priority values will be scheduled before jobs with larger priorities. 0 is most urgent. */ 23 | priority?: number; 24 | /** Number of seconds to wait before putting the job in the ready queue. Job will be in "delayed" state during this time. Maximum is 2**32-1. */ 25 | delay?: number; 26 | /** Time to run - number of seconds to allow a worker to run this job. Minimum is 1. If 0 is provided, server will use 1. Maximum is 2**32-1. */ 27 | ttr?: number; 28 | } 29 | /** 30 | * Raw job data returned from reserveRaw 31 | */ 32 | export interface JackdJobRaw { 33 | /** Unique job ID for this instance of beanstalkd */ 34 | id: number; 35 | /** Raw job payload as bytes */ 36 | payload: Uint8Array; 37 | } 38 | /** 39 | * Job data with decoded string payload 40 | */ 41 | export interface JackdJob { 42 | /** Unique job ID for this instance of beanstalkd */ 43 | id: number; 44 | /** Job payload decoded as UTF-8 string */ 45 | payload: string; 46 | } 47 | /** 48 | * Stats for a specific job 49 | */ 50 | export interface JobStats { 51 | /** Job ID */ 52 | id: number; 53 | /** Name of tube containing this job */ 54 | tube: string; 55 | /** Current state of the job */ 56 | state: "ready" | "delayed" | "reserved" | "buried"; 57 | /** Priority value set by put/release/bury */ 58 | pri: number; 59 | /** Time in seconds since job creation */ 60 | age: number; 61 | /** Seconds remaining until job is put in ready queue */ 62 | delay: number; 63 | /** Time to run in seconds */ 64 | ttr: number; 65 | /** Seconds until server puts job into ready queue (only meaningful if reserved/delayed) */ 66 | timeLeft: number; 67 | /** Binlog file number containing this job (0 if binlog disabled) */ 68 | file: number; 69 | /** Number of times job has been reserved */ 70 | reserves: number; 71 | /** Number of times job has timed out during reservation */ 72 | timeouts: number; 73 | /** Number of times job has been released */ 74 | releases: number; 75 | /** Number of times job has been buried */ 76 | buries: number; 77 | /** Number of times job has been kicked */ 78 | kicks: number; 79 | } 80 | /** 81 | * Stats for a specific tube 82 | */ 83 | export interface TubeStats { 84 | /** Tube name */ 85 | name: string; 86 | /** Number of ready jobs with priority < 1024 */ 87 | currentJobsUrgent: number; 88 | /** Number of jobs in ready queue */ 89 | currentJobsReady: number; 90 | /** Number of jobs reserved by all clients */ 91 | currentJobsReserved: number; 92 | /** Number of delayed jobs */ 93 | currentJobsDelayed: number; 94 | /** Number of buried jobs */ 95 | currentJobsBuried: number; 96 | /** Total jobs created in this tube */ 97 | totalJobs: number; 98 | /** Number of open connections using this tube */ 99 | currentUsing: number; 100 | /** Number of connections waiting on reserve */ 101 | currentWaiting: number; 102 | /** Number of connections watching this tube */ 103 | currentWatching: number; 104 | /** Seconds tube is paused for */ 105 | pause: number; 106 | /** Total delete commands for this tube */ 107 | cmdDelete: number; 108 | /** Total pause-tube commands for this tube */ 109 | cmdPauseTube: number; 110 | /** Seconds until tube is unpaused */ 111 | pauseTimeLeft: number; 112 | } 113 | /** 114 | * System-wide statistics 115 | */ 116 | export interface SystemStats { 117 | /** Number of ready jobs with priority < 1024 */ 118 | currentJobsUrgent: number; 119 | /** Number of jobs in ready queue */ 120 | currentJobsReady: number; 121 | /** Number of jobs reserved by all clients */ 122 | currentJobsReserved: number; 123 | /** Number of delayed jobs */ 124 | currentJobsDelayed: number; 125 | /** Number of buried jobs */ 126 | currentJobsBuried: number; 127 | /** Total put commands */ 128 | cmdPut: number; 129 | /** Total peek commands */ 130 | cmdPeek: number; 131 | /** Total peek-ready commands */ 132 | cmdPeekReady: number; 133 | /** Total peek-delayed commands */ 134 | cmdPeekDelayed: number; 135 | /** Total peek-buried commands */ 136 | cmdPeekBuried: number; 137 | /** Total reserve commands */ 138 | cmdReserve: number; 139 | /** Total reserve-with-timeout commands */ 140 | cmdReserveWithTimeout: number; 141 | /** Total touch commands */ 142 | cmdTouch: number; 143 | /** Total use commands */ 144 | cmdUse: number; 145 | /** Total watch commands */ 146 | cmdWatch: number; 147 | /** Total ignore commands */ 148 | cmdIgnore: number; 149 | /** Total delete commands */ 150 | cmdDelete: number; 151 | /** Total release commands */ 152 | cmdRelease: number; 153 | /** Total bury commands */ 154 | cmdBury: number; 155 | /** Total kick commands */ 156 | cmdKick: number; 157 | /** Total stats commands */ 158 | cmdStats: number; 159 | /** Total stats-job commands */ 160 | cmdStatsJob: number; 161 | /** Total stats-tube commands */ 162 | cmdStatsTube: number; 163 | /** Total list-tubes commands */ 164 | cmdListTubes: number; 165 | /** Total list-tube-used commands */ 166 | cmdListTubeUsed: number; 167 | /** Total list-tubes-watched commands */ 168 | cmdListTubesWatched: number; 169 | /** Total pause-tube commands */ 170 | cmdPauseTube: number; 171 | /** Total job timeouts */ 172 | jobTimeouts: number; 173 | /** Total jobs created */ 174 | totalJobs: number; 175 | /** Maximum job size in bytes */ 176 | maxJobSize: number; 177 | /** Number of currently existing tubes */ 178 | currentTubes: number; 179 | /** Number of currently open connections */ 180 | currentConnections: number; 181 | /** Number of open connections that have issued at least one put */ 182 | currentProducers: number; 183 | /** Number of open connections that have issued at least one reserve */ 184 | currentWorkers: number; 185 | /** Number of connections waiting on reserve */ 186 | currentWaiting: number; 187 | /** Total connections */ 188 | totalConnections: number; 189 | /** Process ID of server */ 190 | pid: number; 191 | /** Version string of server */ 192 | version: string; 193 | /** User CPU time of process */ 194 | rusageUtime: number; 195 | /** System CPU time of process */ 196 | rusageStime: number; 197 | /** Seconds since server started */ 198 | uptime: number; 199 | /** Index of oldest binlog file needed */ 200 | binlogOldestIndex: number; 201 | /** Index of current binlog file */ 202 | binlogCurrentIndex: number; 203 | /** Maximum binlog file size */ 204 | binlogMaxSize: number; 205 | /** Total records written to binlog */ 206 | binlogRecordsWritten: number; 207 | /** Total records migrated in binlog */ 208 | binlogRecordsMigrated: number; 209 | /** Whether server is in drain mode */ 210 | draining: boolean; 211 | /** Random ID of server process */ 212 | id: string; 213 | /** Server hostname */ 214 | hostname: string; 215 | /** Server OS version */ 216 | os: string; 217 | /** Server machine architecture */ 218 | platform: string; 219 | } 220 | /** 221 | * Options for releasing a job back to ready queue 222 | */ 223 | export interface JackdReleaseOpts { 224 | /** New priority to assign to job */ 225 | priority?: number; 226 | /** Seconds to wait before putting job in ready queue */ 227 | delay?: number; 228 | } 229 | /** 230 | * Options for pausing a tube 231 | */ 232 | export interface JackdPauseTubeOpts { 233 | /** Seconds to pause the tube for */ 234 | delay?: number; 235 | } 236 | export type JackdPutArgs = [ 237 | payload: Uint8Array | string | object, 238 | options?: JackdPutOpts 239 | ]; 240 | export type JackdReleaseArgs = [jobId: number, options?: JackdReleaseOpts]; 241 | export type JackdPauseTubeArgs = [tubeId: string, options?: JackdPauseTubeOpts]; 242 | export type JackdJobArgs = [jobId: number]; 243 | export type JackdTubeArgs = [tubeId: string]; 244 | export type JackdBuryArgs = [jobId: number, priority?: number]; 245 | export type JackdArgs = JackdPutArgs | JackdReleaseArgs | JackdPauseTubeArgs | JackdJobArgs | JackdTubeArgs | JackdBuryArgs | never[] | number[] | string[] | [jobId: number, priority?: number]; 246 | /** 247 | * Client options 248 | */ 249 | export type JackdProps = { 250 | /** Whether to automatically connect to the server */ 251 | autoconnect?: boolean; 252 | /** Hostname of beanstalkd server */ 253 | host?: string; 254 | /** Port number, defaults to 11300 */ 255 | port?: number; 256 | /** Whether to automatically reconnect on connection loss */ 257 | autoReconnect?: boolean; 258 | /** Initial delay in ms between reconnection attempts */ 259 | initialReconnectDelay?: number; 260 | /** Maximum delay in ms between reconnection attempts */ 261 | maxReconnectDelay?: number; 262 | /** Maximum number of reconnection attempts (0 for infinite) */ 263 | maxReconnectAttempts?: number; 264 | }; 265 | /** 266 | * Standardized error codes for Jackd operations 267 | */ 268 | export declare enum JackdErrorCode { 269 | /** Server out of memory */ 270 | OUT_OF_MEMORY = "OUT_OF_MEMORY", 271 | /** Internal server error */ 272 | INTERNAL_ERROR = "INTERNAL_ERROR", 273 | /** Bad command format */ 274 | BAD_FORMAT = "BAD_FORMAT", 275 | /** Unknown command */ 276 | UNKNOWN_COMMAND = "UNKNOWN_COMMAND", 277 | /** Job body not properly terminated */ 278 | EXPECTED_CRLF = "EXPECTED_CRLF", 279 | /** Job larger than max-job-size */ 280 | JOB_TOO_BIG = "JOB_TOO_BIG", 281 | /** Server in drain mode */ 282 | DRAINING = "DRAINING", 283 | /** Timeout exceeded with no job */ 284 | TIMED_OUT = "TIMED_OUT", 285 | /** Reserved job TTR expiring */ 286 | DEADLINE_SOON = "DEADLINE_SOON", 287 | /** Resource not found */ 288 | NOT_FOUND = "NOT_FOUND", 289 | /** Cannot ignore only watched tube */ 290 | NOT_IGNORED = "NOT_IGNORED", 291 | /** Unexpected server response */ 292 | INVALID_RESPONSE = "INVALID_RESPONSE", 293 | /** Socket is not connected */ 294 | NOT_CONNECTED = "NOT_CONNECTED", 295 | /** Fatal connection error */ 296 | FATAL_CONNECTION_ERROR = "FATAL_CONNECTION_ERROR" 297 | } 298 | /** 299 | * Custom error class for Jackd operations 300 | */ 301 | export declare class JackdError extends Error { 302 | /** Error code indicating the type of error */ 303 | code: JackdErrorCode; 304 | /** Raw response from server if available */ 305 | response?: string; 306 | constructor(code: JackdErrorCode, message?: string, response?: string); 307 | } 308 | export declare const RESERVED = "RESERVED"; 309 | export declare const INSERTED = "INSERTED"; 310 | export declare const USING = "USING"; 311 | export declare const TOUCHED = "TOUCHED"; 312 | export declare const DELETED = "DELETED"; 313 | export declare const BURIED = "BURIED"; 314 | export declare const RELEASED = "RELEASED"; 315 | export declare const NOT_FOUND = "NOT_FOUND"; 316 | export declare const OUT_OF_MEMORY = "OUT_OF_MEMORY"; 317 | export declare const INTERNAL_ERROR = "INTERNAL_ERROR"; 318 | export declare const BAD_FORMAT = "BAD_FORMAT"; 319 | export declare const UNKNOWN_COMMAND = "UNKNOWN_COMMAND"; 320 | export declare const EXPECTED_CRLF = "EXPECTED_CRLF"; 321 | export declare const JOB_TOO_BIG = "JOB_TOO_BIG"; 322 | export declare const DRAINING = "DRAINING"; 323 | export declare const TIMED_OUT = "TIMED_OUT"; 324 | export declare const DEADLINE_SOON = "DEADLINE_SOON"; 325 | export declare const FOUND = "FOUND"; 326 | export declare const WATCHING = "WATCHING"; 327 | export declare const NOT_IGNORED = "NOT_IGNORED"; 328 | export declare const KICKED = "KICKED"; 329 | export declare const PAUSED = "PAUSED"; 330 | export declare const OK = "OK"; 331 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | services: 3 | beanstalkd: 4 | container_name: jackd_beanstalkd 5 | image: ghcr.io/beanstalkd/beanstalkd:latest 6 | ports: 7 | - '11300:11300' 8 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tseslint from "typescript-eslint" 2 | import js from "@eslint/js" 3 | import unusedImports from "eslint-plugin-unused-imports" 4 | 5 | const config = [ 6 | js.configs.recommended, 7 | ...tseslint.configs.recommendedTypeChecked, 8 | { 9 | languageOptions: { 10 | parserOptions: { 11 | projectService: true, 12 | tsconfigRootDir: import.meta.dirname 13 | } 14 | } 15 | }, 16 | { 17 | plugins: { 18 | "unused-imports": unusedImports 19 | }, 20 | rules: { 21 | "unused-imports/no-unused-imports": "error", 22 | "@typescript-eslint/consistent-type-imports": [ 23 | "error", 24 | { 25 | prefer: "type-imports", 26 | fixStyle: "separate-type-imports" 27 | } 28 | ] 29 | } 30 | } 31 | ] 32 | 33 | export default config 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jackd", 3 | "version": "4.0.7", 4 | "description": "Modern beanstalkd client for Node.js", 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "types": "./dist/types/index.d.ts", 9 | "import": "./dist/esm/index.js", 10 | "require": "./dist/cjs/index.cjs" 11 | } 12 | }, 13 | "main": "./dist/cjs/index.cjs", 14 | "module": "./dist/esm/index.js", 15 | "types": "./dist/types/index.d.ts", 16 | "scripts": { 17 | "build": "tsc --project tsconfig.build.json && bun build.ts", 18 | "dev": "tsc --watch", 19 | "lint": "eslint src" 20 | }, 21 | "files": [ 22 | "dist/" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/getjackd/jackd.git" 27 | }, 28 | "keywords": [ 29 | "beanstalkd" 30 | ], 31 | "author": "Dexter Miguel", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/getjackd/jackd/issues" 35 | }, 36 | "homepage": "https://github.com/getjackd/jackd#readme", 37 | "devDependencies": { 38 | "@eslint/js": "9.18.0", 39 | "@types/node": "22.10.7", 40 | "esbuild": "0.25.0", 41 | "eslint": "9.18.0", 42 | "eslint-plugin-unused-imports": "4.1.4", 43 | "typescript": "5.7.3", 44 | "typescript-eslint": "8.20.0", 45 | "bun-types": "^1.2.2" 46 | }, 47 | "dependencies": { 48 | "yaml": "2.7.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/camelcase.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/sindresorhus/camelcase/blob/main/index.js 2 | 3 | const UPPERCASE = /[\p{Lu}]/u 4 | const LOWERCASE = /[\p{Ll}]/u 5 | const LEADING_CAPITAL = /^[\p{Lu}](?![\p{Lu}])/gu 6 | const IDENTIFIER = /([\p{Alpha}\p{N}_]|$)/u 7 | const SEPARATORS = /[_.\- ]+/ 8 | 9 | const LEADING_SEPARATORS = new RegExp("^" + SEPARATORS.source) 10 | const SEPARATORS_AND_IDENTIFIER = new RegExp( 11 | SEPARATORS.source + IDENTIFIER.source, 12 | "gu" 13 | ) 14 | const NUMBERS_AND_IDENTIFIER = new RegExp("\\d+" + IDENTIFIER.source, "gu") 15 | 16 | interface CamelCaseOptions { 17 | pascalCase?: boolean 18 | preserveConsecutiveUppercase?: boolean 19 | locale?: string | false 20 | } 21 | 22 | type StringTransformer = (str: string) => string 23 | 24 | const preserveCamelCase = ( 25 | str: string, 26 | toLowerCase: StringTransformer, 27 | toUpperCase: StringTransformer, 28 | preserveConsecutiveUppercase: boolean 29 | ): string => { 30 | let isLastCharLower = false 31 | let isLastCharUpper = false 32 | let isLastLastCharUpper = false 33 | let isLastLastCharPreserved = false 34 | 35 | for (let index = 0; index < str.length; index++) { 36 | const character = str[index] 37 | isLastLastCharPreserved = index > 2 ? str[index - 3] === "-" : true 38 | 39 | if (isLastCharLower && UPPERCASE.test(character)) { 40 | str = str.slice(0, index) + "-" + str.slice(index) 41 | isLastCharLower = false 42 | isLastLastCharUpper = isLastCharUpper 43 | isLastCharUpper = true 44 | index++ 45 | } else if ( 46 | isLastCharUpper && 47 | isLastLastCharUpper && 48 | LOWERCASE.test(character) && 49 | (!isLastLastCharPreserved || preserveConsecutiveUppercase) 50 | ) { 51 | str = str.slice(0, index - 1) + "-" + str.slice(index - 1) 52 | isLastLastCharUpper = isLastCharUpper 53 | isLastCharUpper = false 54 | isLastCharLower = true 55 | } else { 56 | isLastCharLower = 57 | toLowerCase(character) === character && 58 | toUpperCase(character) !== character 59 | isLastLastCharUpper = isLastCharUpper 60 | isLastCharUpper = 61 | toUpperCase(character) === character && 62 | toLowerCase(character) !== character 63 | } 64 | } 65 | 66 | return str 67 | } 68 | 69 | const preserveConsecutiveUppercase = ( 70 | input: string, 71 | toLowerCase: StringTransformer 72 | ): string => { 73 | LEADING_CAPITAL.lastIndex = 0 74 | return input.replaceAll(LEADING_CAPITAL, match => toLowerCase(match)) 75 | } 76 | 77 | const postProcess = (input: string, toUpperCase: StringTransformer): string => { 78 | SEPARATORS_AND_IDENTIFIER.lastIndex = 0 79 | NUMBERS_AND_IDENTIFIER.lastIndex = 0 80 | 81 | return input 82 | .replaceAll( 83 | NUMBERS_AND_IDENTIFIER, 84 | (match: string, _pattern: string, offset: number) => 85 | ["_", "-"].includes(input.charAt(offset + match.length)) 86 | ? match 87 | : toUpperCase(match) 88 | ) 89 | .replaceAll(SEPARATORS_AND_IDENTIFIER, (_: string, identifier: string) => 90 | toUpperCase(identifier) 91 | ) 92 | } 93 | 94 | export default function camelCase( 95 | input: string | string[], 96 | options?: CamelCaseOptions 97 | ): string { 98 | if (!(typeof input === "string" || Array.isArray(input))) { 99 | throw new TypeError("Expected the input to be `string | string[]`") 100 | } 101 | 102 | options = { 103 | pascalCase: false, 104 | preserveConsecutiveUppercase: false, 105 | ...options 106 | } 107 | 108 | if (Array.isArray(input)) { 109 | input = input 110 | .map(x => x.trim()) 111 | .filter(x => x.length) 112 | .join("-") 113 | } else { 114 | input = input.trim() 115 | } 116 | 117 | if (input.length === 0) { 118 | return "" 119 | } 120 | 121 | const toLowerCase: StringTransformer = 122 | options.locale === false 123 | ? (string: string) => string.toLowerCase() 124 | : (string: string) => 125 | string.toLocaleLowerCase( 126 | options.locale as string | string[] | undefined 127 | ) 128 | 129 | const toUpperCase: StringTransformer = 130 | options.locale === false 131 | ? (string: string) => string.toUpperCase() 132 | : (string: string) => 133 | string.toLocaleUpperCase( 134 | options.locale as string | string[] | undefined 135 | ) 136 | 137 | if (input.length === 1) { 138 | if (SEPARATORS.test(input)) { 139 | return "" 140 | } 141 | 142 | return options.pascalCase ? toUpperCase(input) : toLowerCase(input) 143 | } 144 | 145 | const hasUpperCase = input !== toLowerCase(input) 146 | 147 | if (hasUpperCase) { 148 | input = preserveCamelCase( 149 | input, 150 | toLowerCase, 151 | toUpperCase, 152 | options.preserveConsecutiveUppercase || false 153 | ) 154 | } 155 | 156 | input = input.replace(LEADING_SEPARATORS, "") 157 | input = options.preserveConsecutiveUppercase 158 | ? preserveConsecutiveUppercase(input, toLowerCase) 159 | : toLowerCase(input) 160 | 161 | if (options.pascalCase) { 162 | input = toUpperCase(input.charAt(0)) + input.slice(1) 163 | } 164 | 165 | return postProcess(input, toUpperCase) 166 | } 167 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import Jackd from "./index" 2 | import crypto from "crypto" 3 | import { describe, it, expect, beforeEach, afterEach } from "bun:test" 4 | import { JackdErrorCode, JackdError } from "./types" 5 | 6 | describe("jackd", () => { 7 | let client: Jackd 8 | 9 | it("can connect to and disconnect from beanstalkd", async () => { 10 | const c = new Jackd() 11 | await c.connect() 12 | await c.close() 13 | }) 14 | 15 | describe("connectivity", () => { 16 | setupTestSuiteLifecycleWithClient() 17 | 18 | it("connected", () => { 19 | expect(client.connected).toBeTruthy() 20 | }) 21 | 22 | it("disconnected", async () => { 23 | await client.disconnect() 24 | expect(client.connected).toBeFalsy() 25 | }) 26 | }) 27 | 28 | describe("reconnection", () => { 29 | it("queues commands when not connected and executes them on connect", async () => { 30 | const client = new Jackd({ autoconnect: false }) 31 | expect(client.connected).toBeFalsy() 32 | 33 | // Queue up a command 34 | const putPromise = client.put("test message") 35 | 36 | // Connect after queueing 37 | await client.connect() 38 | 39 | // Command should complete and return job id 40 | const id = await putPromise 41 | expect(id).toBeDefined() 42 | 43 | // Cleanup 44 | await client.delete(id) 45 | await client.close() 46 | }) 47 | 48 | it("maintains tube watching state across reconnections", async () => { 49 | // Create a new client with autoReconnect enabled and shorter timeout 50 | const client = new Jackd({ 51 | autoconnect: false, 52 | autoReconnect: true, 53 | initialReconnectDelay: 100 // Faster reconnect for test 54 | }) 55 | 56 | await client.connect() 57 | 58 | // Watch new tubes before ignoring default 59 | await client.watch("tube1") 60 | await client.watch("tube2") 61 | // Now we can safely ignore default since we're watching other tubes 62 | await client.ignore("default") 63 | 64 | // Get initial list of watched tubes 65 | const initialWatched = await client.listTubesWatched() 66 | expect(initialWatched).toContain("tube1") 67 | expect(initialWatched).toContain("tube2") 68 | expect(initialWatched).not.toContain("default") 69 | expect(initialWatched.length).toBe(2) // Should be exactly these two tubes 70 | 71 | // Verify the internal state 72 | // @ts-expect-error: testing private property 73 | const internalWatchedTubes = Array.from(client.watchedTubes) 74 | expect(internalWatchedTubes).toContain("tube1") 75 | expect(internalWatchedTubes).toContain("tube2") 76 | expect(internalWatchedTubes).not.toContain("default") 77 | expect(internalWatchedTubes.length).toBe(2) // Should be exactly these two tubes 78 | 79 | // Force a disconnect by destroying the socket 80 | client.socket.destroy() 81 | 82 | // Wait a bit for reconnection 83 | await new Promise(resolve => setTimeout(resolve, 500)) // Shorter wait 84 | 85 | const watchedTubes = await client.listTubesWatched() 86 | expect(watchedTubes).toContain("tube1") 87 | expect(watchedTubes).toContain("tube2") 88 | expect(watchedTubes).not.toContain("default") 89 | expect(watchedTubes.length).toBe(2) 90 | 91 | // Cleanup 92 | await client.close() 93 | }) 94 | 95 | it("maintains use tube state across reconnections", async () => { 96 | // Create a new client with autoReconnect enabled and shorter timeout 97 | const client = new Jackd({ 98 | autoconnect: false, 99 | autoReconnect: true, 100 | initialReconnectDelay: 100 // Faster reconnect for test 101 | }) 102 | await client.connect() 103 | 104 | // Use a different tube 105 | const testTube = "test-tube-use" 106 | await client.use(testTube) 107 | 108 | // Verify current tube 109 | const currentTube = await client.listTubeUsed() 110 | expect(currentTube).toBe(testTube) 111 | 112 | // Force a disconnect by destroying the socket 113 | client.socket.destroy() 114 | 115 | // Wait a bit for reconnection 116 | await new Promise(resolve => setTimeout(resolve, 500)) // Shorter wait 117 | 118 | // Verify the tube is still being used after reconnection 119 | const reconnectedTube = await client.listTubeUsed() 120 | expect(reconnectedTube).toBe(testTube) 121 | 122 | // Cleanup 123 | await client.close() 124 | }) 125 | }) 126 | 127 | describe("producers", () => { 128 | setupTestSuiteLifecycleWithClient() 129 | 130 | it("can insert jobs", async () => { 131 | let id 132 | 133 | try { 134 | id = await client.put("some random job") 135 | expect(id).toBeDefined() 136 | } finally { 137 | if (id) await client.delete(id) 138 | } 139 | }) 140 | 141 | it("can insert jobs with objects", async () => { 142 | let id: number | undefined 143 | 144 | try { 145 | id = await client.put({ foo: "bar" }) 146 | expect(id).toBeDefined() 147 | 148 | const job = await client.reserve() 149 | expect(job.payload).toEqual('{"foo":"bar"}') 150 | } finally { 151 | if (id !== undefined) await client.delete(id) 152 | } 153 | }) 154 | 155 | it("can insert jobs with priority", async () => { 156 | let id 157 | 158 | try { 159 | id = await client.put({ foo: "bar" }, { priority: 12342342 }) 160 | expect(id).toBeDefined() 161 | 162 | const job = await client.reserve() 163 | expect(job.payload).toEqual('{"foo":"bar"}') 164 | } finally { 165 | if (id) await client.delete(id) 166 | } 167 | }) 168 | }) 169 | 170 | describe("consumers", () => { 171 | setupTestSuiteLifecycleWithClient() 172 | 173 | it("can reserve jobs", async () => { 174 | let id: number | undefined 175 | 176 | try { 177 | id = await client.put("some random job") 178 | const job = await client.reserve() 179 | 180 | expect(job.id).toEqual(id) 181 | expect(job.payload).toEqual("some random job") 182 | } finally { 183 | if (id !== undefined) await client.delete(id) 184 | } 185 | }) 186 | 187 | it("can reserve jobs with raw payload", async () => { 188 | let id: number | undefined 189 | 190 | try { 191 | const testString = "some random job" 192 | id = await client.put(testString) 193 | const job = await client.reserveRaw() 194 | 195 | expect(job.id).toEqual(id) 196 | expect(new TextDecoder().decode(job.payload)).toEqual(testString) 197 | } finally { 198 | if (id !== undefined) await client.delete(id) 199 | } 200 | }) 201 | 202 | it("can reserve delayed jobs", async () => { 203 | let id 204 | 205 | try { 206 | id = await client.put("some random job", { 207 | delay: 1 208 | }) 209 | 210 | const job = await client.reserve() 211 | 212 | expect(job.id).toEqual(id) 213 | expect(job.payload).toEqual("some random job") 214 | } finally { 215 | if (id) await client.delete(id) 216 | } 217 | }) 218 | 219 | it("can reserve jobs by id", async () => { 220 | let id: number | undefined 221 | 222 | try { 223 | id = await client.put("some random job", { 224 | delay: 1 225 | }) 226 | 227 | const job = await client.reserveJob(id) 228 | expect(job.payload).toEqual("some random job") 229 | } finally { 230 | if (id !== undefined) await client.delete(id) 231 | } 232 | }) 233 | 234 | it("handles not found", async () => { 235 | let error: unknown 236 | try { 237 | await client.reserveJob(4) 238 | } catch (err) { 239 | error = err 240 | } 241 | expect(error).toBeInstanceOf(JackdError) 242 | const jackdError = error as JackdError 243 | expect(jackdError.code).toBe(JackdErrorCode.NOT_FOUND) 244 | }) 245 | 246 | it("can insert and process jobs on a different tube", async () => { 247 | let id 248 | try { 249 | await client.use("some-other-tube") 250 | id = await client.put("some random job on another tube") 251 | 252 | await client.watch("some-other-tube") 253 | const job = await client.reserve() 254 | 255 | expect(job.id).toEqual(id) 256 | expect(job.payload).toEqual("some random job on another tube") 257 | } finally { 258 | if (id) await client.delete(id) 259 | } 260 | }) 261 | 262 | it("will ignore jobs from default", async () => { 263 | let id, defaultId 264 | try { 265 | defaultId = await client.put("job on default") 266 | await client.use("some-other-tube") 267 | id = await client.put("some random job on another tube") 268 | 269 | await client.watch("some-other-tube") 270 | await client.ignore("default") 271 | 272 | const job = await client.reserve() 273 | 274 | expect(job.id).toEqual(id) 275 | expect(job.payload).toEqual("some random job on another tube") 276 | } finally { 277 | if (id) await client.delete(id) 278 | if (defaultId) await client.delete(defaultId) 279 | } 280 | }) 281 | 282 | it("handles multiple promises fired at once", async () => { 283 | let id1, id2 284 | 285 | try { 286 | await client.use("some-tube") 287 | const firstJobPromise = client.put("some-job") 288 | await client.watch("some-random-tube") 289 | await client.use("some-another-tube") 290 | const secondJobPromise = client.put("some-job") 291 | 292 | id1 = await firstJobPromise 293 | id2 = await secondJobPromise 294 | } finally { 295 | if (id1) await client.delete(id1) 296 | if (id2) await client.delete(id2) 297 | } 298 | }) 299 | 300 | it("can receive huge jobs", async () => { 301 | let id 302 | 303 | try { 304 | // job larger than a socket data frame 305 | const hugeText = 306 | crypto.randomBytes(15000).toString("hex") + 307 | "\r\n" + 308 | crypto.randomBytes(15000).toString("hex") 309 | 310 | id = await client.put(hugeText) 311 | const job = await client.reserve() 312 | 313 | expect(job.id).toEqual(id) 314 | expect(job.payload).toEqual(hugeText) 315 | } finally { 316 | if (id) await client.delete(id) 317 | } 318 | }) 319 | 320 | it("can peek buried jobs", async () => { 321 | let id: number | undefined 322 | 323 | try { 324 | await client.use("some-tube") 325 | 326 | id = await client.put("some-job") 327 | 328 | await client.watch("some-tube") 329 | await client.reserve() 330 | await client.bury(id) 331 | 332 | const job = await client.peekBuried() 333 | 334 | expect(job.id).toEqual(id) 335 | } finally { 336 | if (id) await client.delete(id) 337 | } 338 | }) 339 | }) 340 | 341 | describe("stats", () => { 342 | setupTestSuiteLifecycleWithClient() 343 | 344 | it("brings back stats", async () => { 345 | const stats = await client.stats() 346 | // Verify numeric fields 347 | expect(typeof stats.currentJobsReady).toBe("number") 348 | expect(typeof stats.totalJobs).toBe("number") 349 | expect(typeof stats.currentConnections).toBe("number") 350 | expect(typeof stats.pid).toBe("number") 351 | expect(typeof stats.uptime).toBe("number") 352 | // Verify string fields 353 | expect(typeof stats.version).toBe("string") 354 | expect(typeof stats.hostname).toBe("string") 355 | expect(typeof stats.os).toBe("string") 356 | // Verify boolean field 357 | expect(typeof stats.draining).toBe("boolean") 358 | }) 359 | 360 | it("brings back job stats", async () => { 361 | let id: number | undefined 362 | try { 363 | id = await client.put("test job") 364 | const stats = await client.statsJob(id) 365 | // Verify numeric fields 366 | expect(typeof stats.id).toBe("number") 367 | expect(stats.id).toBe(id) 368 | // Verify string fields 369 | expect(typeof stats.tube).toBe("string") 370 | expect(stats.tube).toBe("default") 371 | expect(typeof stats.state).toBe("string") 372 | expect(["ready", "delayed", "reserved", "buried"]).toContain( 373 | stats.state 374 | ) 375 | // Verify numeric fields 376 | expect(typeof stats.pri).toBe("number") 377 | expect(typeof stats.age).toBe("number") 378 | expect(typeof stats.delay).toBe("number") 379 | expect(typeof stats.ttr).toBe("number") 380 | expect(typeof stats.timeLeft).toBe("number") 381 | expect(typeof stats.reserves).toBe("number") 382 | expect(typeof stats.timeouts).toBe("number") 383 | expect(typeof stats.releases).toBe("number") 384 | expect(typeof stats.buries).toBe("number") 385 | expect(typeof stats.kicks).toBe("number") 386 | } finally { 387 | if (id !== undefined) await client.delete(id) 388 | } 389 | }) 390 | 391 | it("brings back tube stats", async () => { 392 | const stats = await client.statsTube("default") 393 | // Verify string field 394 | expect(typeof stats.name).toBe("string") 395 | expect(stats.name).toBe("default") 396 | // Verify numeric fields 397 | expect(typeof stats.currentJobsUrgent).toBe("number") 398 | expect(typeof stats.currentJobsReady).toBe("number") 399 | expect(typeof stats.currentJobsReserved).toBe("number") 400 | expect(typeof stats.currentJobsDelayed).toBe("number") 401 | expect(typeof stats.currentJobsBuried).toBe("number") 402 | expect(typeof stats.totalJobs).toBe("number") 403 | expect(typeof stats.currentUsing).toBe("number") 404 | expect(typeof stats.currentWaiting).toBe("number") 405 | expect(typeof stats.currentWatching).toBe("number") 406 | expect(typeof stats.pause).toBe("number") 407 | expect(typeof stats.cmdDelete).toBe("number") 408 | expect(typeof stats.cmdPauseTube).toBe("number") 409 | expect(typeof stats.pauseTimeLeft).toBe("number") 410 | }) 411 | 412 | it("brings back list of tubes", async () => { 413 | const tubes = await client.listTubes() 414 | expect(tubes).toContain("default") 415 | expect(Array.isArray(tubes)).toBe(true) 416 | }) 417 | 418 | it("brings back list of watched tubes", async () => { 419 | // First verify we start with just default 420 | const initialTubes = await client.listTubesWatched() 421 | expect(initialTubes).toContain("default") 422 | expect(initialTubes.length).toBe(1) 423 | 424 | // Watch should return the number of tubes being watched 425 | const watchCount = await client.watch("test-tube") 426 | expect(watchCount).toBe(2) // Should be watching 2 tubes now 427 | 428 | // Now check the list 429 | const tubes = await client.listTubesWatched() 430 | expect(tubes).toContain("default") 431 | expect(tubes).toContain("test-tube") 432 | expect(tubes.length).toBe(2) 433 | expect(Array.isArray(tubes)).toBe(true) 434 | }) 435 | 436 | it("brings back current tube", async () => { 437 | expect(await client.listTubeUsed()).toBe("default") 438 | await client.use("test-tube") 439 | expect(await client.listTubeUsed()).toBe("test-tube") 440 | }) 441 | }) 442 | 443 | describe("bugfixes", () => { 444 | setupTestSuiteLifecycleWithClient() 445 | 446 | it("can receive jobs with new lines jobs", async () => { 447 | let id 448 | 449 | try { 450 | // job larger than a socket data frame 451 | const payload = "this job should not fail!\r\n" 452 | 453 | id = await client.put(payload) 454 | const job = await client.reserve() 455 | 456 | expect(job.id).toEqual(id) 457 | expect(job.payload).toEqual(payload) 458 | } finally { 459 | if (id) await client.delete(id) 460 | } 461 | }) 462 | 463 | it("can continue execution after bad command", async () => { 464 | let id 465 | let error: unknown 466 | 467 | try { 468 | // Bad command 469 | // @ts-expect-error We're testing the error handling 470 | await client.delete("nonexistent job") 471 | } catch (err) { 472 | error = err 473 | } 474 | expect(error).toBeInstanceOf(JackdError) 475 | const jackdError = error as JackdError 476 | expect(jackdError.code).toBe(JackdErrorCode.BAD_FORMAT) 477 | 478 | try { 479 | id = await client.put("my awesome job") 480 | } finally { 481 | if (id) await client.delete(id) 482 | } 483 | }) 484 | }) 485 | 486 | function setupTestSuiteLifecycleWithClient() { 487 | beforeEach(async () => { 488 | client = new Jackd() 489 | await client.connect() 490 | }) 491 | 492 | afterEach(async () => { 493 | await client.close() 494 | }) 495 | } 496 | }) 497 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "net" 2 | import assert from "assert" 3 | import yaml from "yaml" 4 | import camelCase from "./camelcase" 5 | import { 6 | BAD_FORMAT, 7 | BURIED, 8 | DEADLINE_SOON, 9 | DELETED, 10 | DELIMITER, 11 | DRAINING, 12 | EXPECTED_CRLF, 13 | FOUND, 14 | INSERTED, 15 | INTERNAL_ERROR, 16 | JackdError, 17 | JackdErrorCode, 18 | JOB_TOO_BIG, 19 | KICKED, 20 | NOT_FOUND, 21 | NOT_IGNORED, 22 | OK, 23 | OUT_OF_MEMORY, 24 | PAUSED, 25 | RELEASED, 26 | RESERVED, 27 | TIMED_OUT, 28 | TOUCHED, 29 | UNKNOWN_COMMAND, 30 | USING, 31 | WATCHING, 32 | type JackdProps 33 | } from "./types" 34 | import type { 35 | CommandExecution, 36 | CommandHandler, 37 | JackdArgs, 38 | JackdBuryArgs, 39 | JackdJob, 40 | JackdJobArgs, 41 | JackdJobRaw, 42 | JackdPauseTubeArgs, 43 | JackdPayload, 44 | JackdPutArgs, 45 | JackdPutOpts, 46 | JackdReleaseArgs, 47 | JackdTubeArgs, 48 | JobStats, 49 | SystemStats, 50 | TubeStats 51 | } from "./types" 52 | import EventEmitter from "events" 53 | 54 | /** 55 | * Beanstalkd client 56 | * 57 | * ```ts 58 | * import Jackd from "jackd" 59 | * 60 | * const client = new Jackd() 61 | * 62 | * await client.put("Hello!") 63 | * 64 | * // At a later time 65 | * const { id, payload } = await client.reserve() 66 | * console.log({ id, payload }) // => { id: '1', payload: 'Hello!' } 67 | * 68 | * // Process the job, then delete it 69 | * await client.delete(id) 70 | * ``` 71 | */ 72 | export class JackdClient { 73 | public socket: Socket = this.createSocket() 74 | public connected: boolean = false 75 | private chunkLength: number = 0 76 | private host: string 77 | private port: number 78 | private autoReconnect: boolean 79 | private initialReconnectDelay: number 80 | private maxReconnectDelay: number 81 | private maxReconnectAttempts: number 82 | private reconnectAttempts: number = 0 83 | private currentReconnectDelay: number 84 | private reconnectTimeout?: ReturnType 85 | private isReconnecting: boolean = false 86 | private watchedTubes: Set = new Set(["default"]) 87 | private currentTube: string = "default" 88 | 89 | private executions: CommandExecution[] = [] 90 | private buffer: Uint8Array = new Uint8Array() 91 | private commandBuffer: Uint8Array = new Uint8Array() 92 | private isProcessing: boolean = false 93 | 94 | constructor({ 95 | autoconnect = true, 96 | host = "localhost", 97 | port = 11300, 98 | autoReconnect = true, 99 | initialReconnectDelay = 1000, 100 | maxReconnectDelay = 30000, 101 | maxReconnectAttempts = 0 102 | }: JackdProps = {}) { 103 | this.host = host 104 | this.port = port 105 | this.autoReconnect = autoReconnect 106 | this.initialReconnectDelay = initialReconnectDelay 107 | this.maxReconnectDelay = maxReconnectDelay 108 | this.maxReconnectAttempts = maxReconnectAttempts 109 | this.currentReconnectDelay = initialReconnectDelay 110 | 111 | if (autoconnect) { 112 | void this.connect() 113 | } 114 | } 115 | 116 | private createSocket() { 117 | this.socket = new Socket() 118 | this.socket.setKeepAlive(true, 30_000) 119 | 120 | this.socket.on("ready", () => { 121 | this.connected = true 122 | void this.processNextCommand() 123 | }) 124 | 125 | this.socket.on("close", () => { 126 | if (this.connected) this.handleDisconnect() 127 | }) 128 | 129 | this.socket.on("end", () => { 130 | if (this.connected) this.handleDisconnect() 131 | }) 132 | 133 | this.socket.on("error", (error: Error) => { 134 | console.error("Socket error:", error.message) 135 | if (this.connected) this.handleDisconnect() 136 | }) 137 | 138 | // When we receive data from the socket, add it to the buffer. 139 | this.socket.on("data", incoming => { 140 | const newBuffer = new Uint8Array(this.buffer.length + incoming.length) 141 | newBuffer.set(this.buffer) 142 | newBuffer.set(new Uint8Array(incoming), this.buffer.length) 143 | this.buffer = newBuffer 144 | 145 | void this.processNextCommand() 146 | }) 147 | 148 | return this.socket 149 | } 150 | 151 | private handleDisconnect() { 152 | this.connected = false 153 | 154 | if (this.autoReconnect && !this.isReconnecting) { 155 | void this.attemptReconnect() 156 | } 157 | } 158 | 159 | private attemptReconnect() { 160 | if ( 161 | this.maxReconnectAttempts > 0 && 162 | this.reconnectAttempts >= this.maxReconnectAttempts 163 | ) { 164 | console.error("Max reconnection attempts reached") 165 | return 166 | } 167 | 168 | this.isReconnecting = true 169 | this.reconnectAttempts++ 170 | 171 | // Clear any existing timeout 172 | if (this.reconnectTimeout) { 173 | clearTimeout(this.reconnectTimeout) 174 | } 175 | 176 | // Schedule reconnection attempt with exponential backoff 177 | this.reconnectTimeout = setTimeout(() => { 178 | void (async () => { 179 | try { 180 | await this.connect() 181 | this.isReconnecting = false 182 | } catch (error) { 183 | console.error("Reconnection failed:", error) 184 | 185 | // Exponential backoff with max delay 186 | this.currentReconnectDelay = Math.min( 187 | this.currentReconnectDelay * 2, 188 | this.maxReconnectDelay 189 | ) 190 | 191 | // Try again 192 | this.isReconnecting = false 193 | void this.attemptReconnect() 194 | } 195 | })() 196 | }, this.currentReconnectDelay) 197 | } 198 | 199 | /** 200 | * For environments where network partitioning is common. 201 | * @returns {Boolean} 202 | */ 203 | isConnected(): boolean { 204 | return this.connected 205 | } 206 | 207 | async connect(): Promise { 208 | await new Promise((resolve, reject) => { 209 | this.socket.once("error", (error: NodeJS.ErrnoException) => { 210 | if (error.code === "EISCONN") { 211 | return resolve() 212 | } 213 | reject(error) 214 | }) 215 | 216 | this.socket.connect(this.port, this.host, resolve) 217 | }) 218 | 219 | await this.rewatchTubes() 220 | await this.reuseTube() 221 | 222 | return this 223 | } 224 | 225 | /** 226 | * Rewatches all previously watched tubes after a reconnection 227 | * If default is not in the watched tubes list, ignores it 228 | */ 229 | private async rewatchTubes() { 230 | // Watch all tubes in our set (except default) first 231 | for (const tube of this.watchedTubes) { 232 | if (tube !== "default") { 233 | await this.watch(tube) 234 | } 235 | } 236 | 237 | // Only after watching other tubes, ignore default if it's not in our watched set 238 | if (!this.watchedTubes.has("default")) { 239 | await this.ignore("default") 240 | } 241 | } 242 | 243 | /** 244 | * Reuses the previously used tube after a reconnection 245 | */ 246 | private async reuseTube() { 247 | if (this.currentTube !== "default") { 248 | await this.use(this.currentTube) 249 | } 250 | } 251 | 252 | quit = async () => { 253 | if (!this.connected) return 254 | 255 | const waitForClose = new Promise((resolve, reject) => { 256 | this.socket.once("end", resolve) 257 | this.socket.once("close", resolve) 258 | this.socket.once("error", reject) 259 | }) 260 | 261 | await this.write(new TextEncoder().encode("quit\r\n")) 262 | this.socket.destroy() 263 | await waitForClose 264 | } 265 | 266 | close = this.quit 267 | disconnect = this.quit 268 | 269 | createCommandHandler( 270 | commandStringFunction: (...args: TArgs) => Uint8Array, 271 | handlers: CommandHandler[] 272 | ): (...args: TArgs) => Promise { 273 | return async (...args) => { 274 | const commandString: Uint8Array = commandStringFunction.apply(this, args) 275 | 276 | const emitter = new EventEmitter() 277 | 278 | this.executions.push({ 279 | command: commandString, 280 | handlers: handlers.concat(), 281 | emitter, 282 | written: false 283 | }) 284 | 285 | void this.processNextCommand() 286 | 287 | return new Promise((resolve, reject) => { 288 | emitter.once("resolve", resolve) 289 | emitter.once("reject", reject) 290 | }) 291 | } 292 | } 293 | 294 | private async processNextCommand() { 295 | if (this.isProcessing) return 296 | 297 | this.isProcessing = true 298 | 299 | try { 300 | if (!this.connected) return 301 | 302 | while (this.executions.length > 0) { 303 | const execution = this.executions[0] 304 | 305 | if (!execution.written) { 306 | await this.write(execution.command) 307 | execution.written = true 308 | } 309 | 310 | // Now that we've written the command, let's move the front of this.buffer to 311 | // this.commandBuffer until we have a full response. 312 | const delimiter = new TextEncoder().encode(DELIMITER) 313 | 314 | const { handlers, emitter } = execution 315 | 316 | try { 317 | while (handlers.length) { 318 | const handler = handlers[0] 319 | 320 | while (true) { 321 | const index = this.chunkLength 322 | ? this.chunkLength 323 | : findIndex(this.buffer, delimiter) 324 | 325 | if (this.chunkLength && this.buffer.length >= this.chunkLength) { 326 | this.commandBuffer = this.buffer.slice(0, this.chunkLength) 327 | this.buffer = this.buffer.slice( 328 | this.chunkLength + delimiter.length 329 | ) 330 | this.chunkLength = 0 331 | break 332 | } else if (this.chunkLength === 0 && index > -1) { 333 | // If we have a full response, move it to the command buffer and reset the buffer. 334 | this.commandBuffer = this.buffer.slice(0, index) 335 | this.buffer = this.buffer.slice(index + delimiter.length) 336 | break 337 | } 338 | 339 | // If we don't have a full response, wait for more data. 340 | return 341 | } 342 | 343 | const result = await handler( 344 | this.commandBuffer, 345 | execution.command.slice( 346 | 0, 347 | execution.command.length - DELIMITER.length 348 | ) 349 | ) 350 | this.commandBuffer = new Uint8Array() 351 | handlers.shift() 352 | 353 | // If this is the last handler, emit the result. 354 | if (handlers.length === 0) { 355 | emitter.emit("resolve", result) 356 | this.executions.shift() 357 | } 358 | } 359 | } catch (err) { 360 | emitter.emit("reject", err) 361 | this.executions.shift() 362 | } 363 | } 364 | } catch (error) { 365 | console.error("Error processing command:", error) 366 | } finally { 367 | this.isProcessing = false 368 | } 369 | } 370 | 371 | private write(buffer: Uint8Array) { 372 | assert(buffer) 373 | 374 | return new Promise((resolve, reject) => { 375 | this.socket.write(buffer, err => (err ? reject(err) : resolve())) 376 | }) 377 | } 378 | 379 | /** 380 | * Puts a job into the currently used tube 381 | * @param payload Job data - will be JSON stringified if object 382 | * @param options Priority, delay and TTR options 383 | * @returns Job ID 384 | * @throws {Error} BURIED if server out of memory 385 | * @throws {Error} EXPECTED_CRLF if job body not properly terminated 386 | * @throws {Error} JOB_TOO_BIG if job larger than max-job-size 387 | * @throws {Error} DRAINING if server in drain mode 388 | */ 389 | put = this.createCommandHandler( 390 | (payload: JackdPayload, { priority, delay, ttr }: JackdPutOpts = {}) => { 391 | assert(payload) 392 | let body: Uint8Array 393 | 394 | // If the caller passed in an object, convert it to a valid Uint8Array from a JSON string 395 | if (typeof payload === "object") { 396 | const string = JSON.stringify(payload) 397 | body = new TextEncoder().encode(string) 398 | } else { 399 | // Anything else, just capture the Uint8Array 400 | body = new TextEncoder().encode(payload) 401 | } 402 | 403 | const command = new TextEncoder().encode( 404 | `put ${priority || 0} ${delay || 0} ${ttr || 60} ${body.length}\r\n` 405 | ) 406 | 407 | const delimiter = new TextEncoder().encode(DELIMITER) 408 | const result = new Uint8Array( 409 | command.length + body.length + delimiter.length 410 | ) 411 | result.set(command) 412 | result.set(body, command.length) 413 | result.set(delimiter, command.length + body.length) 414 | return result 415 | }, 416 | [ 417 | buffer => { 418 | const ascii = validate(buffer, [ 419 | BURIED, 420 | EXPECTED_CRLF, 421 | JOB_TOO_BIG, 422 | DRAINING 423 | ]) 424 | 425 | if (ascii.startsWith(INSERTED)) { 426 | const [, id] = ascii.split(" ") 427 | return parseInt(id) 428 | } 429 | 430 | invalidResponse(ascii) 431 | } 432 | ] 433 | ) 434 | 435 | /** 436 | * Changes the tube used for subsequent put commands 437 | * @param tube Tube name (max 200 bytes). Created if doesn't exist. 438 | * @returns Name of tube now being used 439 | */ 440 | use = this.createCommandHandler( 441 | tube => { 442 | assert(tube) 443 | return new TextEncoder().encode(`use ${tube}\r\n`) 444 | }, 445 | [ 446 | buffer => { 447 | const ascii = validate(buffer) 448 | 449 | if (ascii.startsWith(USING)) { 450 | const [, tube] = ascii.split(" ") 451 | this.currentTube = tube 452 | return tube 453 | } 454 | 455 | invalidResponse(ascii) 456 | } 457 | ] 458 | ) 459 | 460 | createReserveHandlers( 461 | additionalResponses: Array = [], 462 | decodePayload: boolean = true 463 | ): [CommandHandler, CommandHandler] { 464 | let id: number 465 | 466 | return [ 467 | (buffer: Uint8Array) => { 468 | const ascii = validate(buffer, [ 469 | DEADLINE_SOON, 470 | TIMED_OUT, 471 | ...additionalResponses 472 | ]) 473 | 474 | if (ascii.startsWith(RESERVED)) { 475 | const [, incomingId, bytes] = ascii.split(" ") 476 | id = parseInt(incomingId) 477 | this.chunkLength = parseInt(bytes) 478 | return 479 | } 480 | 481 | invalidResponse(ascii) 482 | }, 483 | (payload: Uint8Array) => { 484 | return { 485 | id, 486 | payload: decodePayload ? new TextDecoder().decode(payload) : payload 487 | } as T 488 | } 489 | ] 490 | } 491 | 492 | /** 493 | * Reserves a job from any watched tube 494 | * @returns Reserved job with string payload 495 | * @throws {Error} DEADLINE_SOON if reserved job TTR expiring 496 | * @throws {Error} TIMED_OUT if timeout exceeded with no job 497 | */ 498 | reserve = this.createCommandHandler<[], JackdJob>( 499 | () => new TextEncoder().encode("reserve\r\n"), 500 | this.createReserveHandlers([], true) 501 | ) 502 | 503 | /** 504 | * Reserves a job with raw byte payload 505 | * @returns Reserved job with raw payload 506 | * @throws {Error} DEADLINE_SOON if reserved job TTR expiring 507 | * @throws {Error} TIMED_OUT if timeout exceeded with no job 508 | */ 509 | reserveRaw = this.createCommandHandler<[], JackdJobRaw>( 510 | () => new TextEncoder().encode("reserve\r\n"), 511 | this.createReserveHandlers([], false) 512 | ) 513 | 514 | /** 515 | * Reserves a job with timeout 516 | * @param seconds Max seconds to wait. 0 returns immediately. 517 | * @returns Reserved job 518 | * @throws {Error} DEADLINE_SOON if reserved job TTR expiring 519 | * @throws {Error} TIMED_OUT if timeout exceeded with no job 520 | */ 521 | reserveWithTimeout = this.createCommandHandler<[number], JackdJob>( 522 | seconds => new TextEncoder().encode(`reserve-with-timeout ${seconds}\r\n`), 523 | this.createReserveHandlers([], true) 524 | ) 525 | 526 | /** 527 | * Reserves a specific job by ID 528 | * @param id Job ID to reserve 529 | * @returns Reserved job 530 | * @throws {Error} NOT_FOUND if job doesn't exist or not reservable 531 | */ 532 | reserveJob = this.createCommandHandler<[number], JackdJob>( 533 | id => new TextEncoder().encode(`reserve-job ${id}\r\n`), 534 | this.createReserveHandlers([NOT_FOUND], true) 535 | ) 536 | 537 | /** 538 | * Deletes a job 539 | * @param id Job ID to delete 540 | * @throws {Error} NOT_FOUND if job doesn't exist or not deletable 541 | */ 542 | delete = this.createCommandHandler( 543 | id => { 544 | assert(id) 545 | return new TextEncoder().encode(`delete ${id}\r\n`) 546 | }, 547 | [ 548 | buffer => { 549 | const ascii = validate(buffer, [NOT_FOUND]) 550 | 551 | if (ascii === DELETED) return 552 | invalidResponse(ascii) 553 | } 554 | ] 555 | ) 556 | 557 | /** 558 | * Releases a reserved job back to ready queue 559 | * @param id Job ID to release 560 | * @param options New priority and delay 561 | * @throws {Error} BURIED if server out of memory 562 | * @throws {Error} NOT_FOUND if job doesn't exist or not reserved by this client 563 | */ 564 | release = this.createCommandHandler( 565 | (id, { priority, delay } = {}) => { 566 | assert(id) 567 | return new TextEncoder().encode( 568 | `release ${id} ${priority || 0} ${delay || 0}\r\n` 569 | ) 570 | }, 571 | [ 572 | buffer => { 573 | const ascii = validate(buffer, [BURIED, NOT_FOUND]) 574 | if (ascii === RELEASED) return 575 | invalidResponse(ascii) 576 | } 577 | ] 578 | ) 579 | 580 | /** 581 | * Buries a job 582 | * @param id Job ID to bury 583 | * @param priority New priority 584 | * @throws {Error} NOT_FOUND if job doesn't exist or not reserved by this client 585 | */ 586 | bury = this.createCommandHandler( 587 | (id, priority) => { 588 | assert(id) 589 | return new TextEncoder().encode(`bury ${id} ${priority || 0}\r\n`) 590 | }, 591 | [ 592 | buffer => { 593 | const ascii = validate(buffer, [NOT_FOUND]) 594 | if (ascii === BURIED) return 595 | invalidResponse(ascii) 596 | } 597 | ] 598 | ) 599 | 600 | /** 601 | * Touches a reserved job, requesting more time to work on it 602 | * @param id Job ID to touch 603 | * @throws {Error} NOT_FOUND if job doesn't exist or not reserved by this client 604 | */ 605 | touch = this.createCommandHandler( 606 | id => { 607 | assert(id) 608 | return new TextEncoder().encode(`touch ${id}\r\n`) 609 | }, 610 | [ 611 | buffer => { 612 | const ascii = validate(buffer, [NOT_FOUND]) 613 | if (ascii === TOUCHED) return 614 | invalidResponse(ascii) 615 | } 616 | ] 617 | ) 618 | 619 | /** 620 | * Adds tube to watch list for reserve commands 621 | * @param tube Tube name to watch (max 200 bytes) 622 | * @returns Number of tubes now being watched 623 | */ 624 | watch = this.createCommandHandler( 625 | tube => { 626 | assert(tube) 627 | return new TextEncoder().encode(`watch ${tube}\r\n`) 628 | }, 629 | [ 630 | (buffer, command) => { 631 | const ascii = validate(buffer) 632 | 633 | if (ascii.startsWith(WATCHING)) { 634 | const tube = new TextDecoder().decode(command).split(" ")[1] 635 | this.watchedTubes.add(tube) 636 | 637 | const [, count] = ascii.split(" ") 638 | return parseInt(count) 639 | } 640 | 641 | invalidResponse(ascii) 642 | } 643 | ] 644 | ) 645 | 646 | /** 647 | * Removes tube from watch list 648 | * @param tube Tube name to ignore 649 | * @returns Number of tubes now being watched 650 | * @throws {Error} NOT_IGNORED if trying to ignore only watched tube 651 | */ 652 | ignore = this.createCommandHandler( 653 | tube => { 654 | assert(tube) 655 | return new TextEncoder().encode(`ignore ${tube}\r\n`) 656 | }, 657 | [ 658 | (buffer, command) => { 659 | const ascii = validate(buffer, [NOT_IGNORED]) 660 | 661 | if (ascii.startsWith(WATCHING)) { 662 | const tube = new TextDecoder().decode(command).split(" ")[1] 663 | this.watchedTubes.delete(tube) 664 | 665 | const [, count] = ascii.split(" ") 666 | return parseInt(count) 667 | } 668 | invalidResponse(ascii) 669 | } 670 | ] 671 | ) 672 | 673 | /** 674 | * Pauses new job reservations in a tube 675 | * @param tube Tube name to pause 676 | * @param delay Seconds to pause for 677 | * @throws {Error} NOT_FOUND if tube doesn't exist 678 | */ 679 | pauseTube = this.createCommandHandler( 680 | (tube, { delay } = {}) => 681 | new TextEncoder().encode(`pause-tube ${tube} ${delay || 0}`), 682 | 683 | [ 684 | buffer => { 685 | const ascii = validate(buffer, [NOT_FOUND]) 686 | if (ascii === PAUSED) return 687 | invalidResponse(ascii) 688 | } 689 | ] 690 | ) 691 | 692 | /* Other commands */ 693 | 694 | /** 695 | * Peeks at a specific job 696 | * @param id Job ID to peek at 697 | * @returns Job data if found 698 | * @throws {Error} NOT_FOUND if job doesn't exist 699 | */ 700 | peek = this.createCommandHandler(id => { 701 | assert(id) 702 | return new TextEncoder().encode(`peek ${id}\r\n`) 703 | }, this.createPeekHandlers()) 704 | 705 | createPeekHandlers(): [CommandHandler, CommandHandler] { 706 | let id: number 707 | 708 | return [ 709 | (buffer: Uint8Array) => { 710 | const ascii = validate(buffer, [NOT_FOUND]) 711 | if (ascii.startsWith(FOUND)) { 712 | const [, peekId, bytes] = ascii.split(" ") 713 | id = parseInt(peekId) 714 | this.chunkLength = parseInt(bytes) 715 | return 716 | } 717 | invalidResponse(ascii) 718 | }, 719 | (payload: Uint8Array) => { 720 | return { 721 | id, 722 | payload: new TextDecoder().decode(payload) 723 | } 724 | } 725 | ] 726 | } 727 | 728 | /** 729 | * Peeks at the next ready job in the currently used tube 730 | * @returns Job data if found 731 | * @throws {Error} NOT_FOUND if no ready jobs 732 | */ 733 | peekReady = this.createCommandHandler<[], JackdJob>( 734 | () => new TextEncoder().encode(`peek-ready\r\n`), 735 | this.createPeekHandlers() 736 | ) 737 | 738 | /** 739 | * Peeks at the delayed job with shortest delay in currently used tube 740 | * @returns Job data if found 741 | * @throws {Error} NOT_FOUND if no delayed jobs 742 | */ 743 | peekDelayed = this.createCommandHandler<[], JackdJob>( 744 | () => new TextEncoder().encode(`peek-delayed\r\n`), 745 | this.createPeekHandlers() 746 | ) 747 | 748 | /** 749 | * Peeks at the next buried job in currently used tube 750 | * @returns Job data if found 751 | * @throws {Error} NOT_FOUND if no buried jobs 752 | */ 753 | peekBuried = this.createCommandHandler<[], JackdJob>( 754 | () => new TextEncoder().encode(`peek-buried\r\n`), 755 | this.createPeekHandlers() 756 | ) 757 | 758 | /** 759 | * Kicks at most bound jobs from buried to ready queue in currently used tube 760 | * @param bound Maximum number of jobs to kick 761 | * @returns Number of jobs actually kicked 762 | */ 763 | kick = this.createCommandHandler<[jobsCount: number], number>( 764 | bound => { 765 | assert(bound) 766 | return new TextEncoder().encode(`kick ${bound}\r\n`) 767 | }, 768 | [ 769 | buffer => { 770 | const ascii = validate(buffer) 771 | if (ascii.startsWith(KICKED)) { 772 | const [, kicked] = ascii.split(" ") 773 | return parseInt(kicked) 774 | } 775 | 776 | invalidResponse(ascii) 777 | } 778 | ] 779 | ) 780 | 781 | /** 782 | * Kicks a specific buried or delayed job into ready queue 783 | * @param id Job ID to kick 784 | * @throws {Error} NOT_FOUND if job doesn't exist or not in kickable state 785 | */ 786 | kickJob = this.createCommandHandler( 787 | id => { 788 | assert(id) 789 | return new TextEncoder().encode(`kick-job ${id}\r\n`) 790 | }, 791 | [ 792 | buffer => { 793 | const ascii = validate(buffer, [NOT_FOUND]) 794 | if (ascii.startsWith(KICKED)) return 795 | invalidResponse(ascii) 796 | } 797 | ] 798 | ) 799 | 800 | /** 801 | * Gets statistical information about a job 802 | * @param id Job ID 803 | * @returns Job statistics 804 | * @throws {Error} NOT_FOUND if job doesn't exist 805 | */ 806 | statsJob = this.createCommandHandler( 807 | id => { 808 | assert(id) 809 | return new TextEncoder().encode(`stats-job ${id}\r\n`) 810 | }, 811 | [ 812 | (buffer: Uint8Array) => { 813 | const ascii = validate(buffer, [NOT_FOUND]) 814 | 815 | if (ascii.startsWith(OK)) { 816 | const [, bytes] = ascii.split(" ") 817 | this.chunkLength = parseInt(bytes) 818 | return 819 | } 820 | 821 | invalidResponse(ascii) 822 | }, 823 | (payload: Uint8Array): JobStats => { 824 | const decodedString = new TextDecoder().decode(payload) 825 | const rawStats = yaml.parse(decodedString) as Record 826 | const transformedStats = Object.fromEntries( 827 | Object.entries(rawStats).map(([key, value]) => [ 828 | camelCase(key), 829 | value 830 | ]) 831 | ) 832 | return transformedStats as unknown as JobStats 833 | } 834 | ] 835 | ) 836 | 837 | /** 838 | * Gets statistical information about a tube 839 | * @param tube Tube name 840 | * @returns Tube statistics 841 | * @throws {Error} NOT_FOUND if tube doesn't exist 842 | */ 843 | statsTube = this.createCommandHandler( 844 | tube => { 845 | assert(tube) 846 | return new TextEncoder().encode(`stats-tube ${tube}\r\n`) 847 | }, 848 | [ 849 | (buffer: Uint8Array) => { 850 | const ascii = validate(buffer, [NOT_FOUND]) 851 | 852 | if (ascii.startsWith(OK)) { 853 | const [, bytes] = ascii.split(" ") 854 | this.chunkLength = parseInt(bytes) 855 | return 856 | } 857 | 858 | invalidResponse(ascii) 859 | }, 860 | (payload: Uint8Array): TubeStats => { 861 | const decodedString = new TextDecoder().decode(payload) 862 | const rawStats = yaml.parse(decodedString) as Record 863 | const transformedStats = Object.fromEntries( 864 | Object.entries(rawStats).map(([key, value]) => [ 865 | camelCase(key), 866 | value 867 | ]) 868 | ) 869 | return transformedStats as unknown as TubeStats 870 | } 871 | ] 872 | ) 873 | 874 | /** 875 | * Gets statistical information about the system 876 | * @returns System statistics 877 | */ 878 | stats = this.createCommandHandler<[], SystemStats>( 879 | () => new TextEncoder().encode(`stats\r\n`), 880 | [ 881 | (buffer: Uint8Array) => { 882 | const ascii = validate(buffer) 883 | 884 | if (ascii.startsWith(OK)) { 885 | const [, bytes] = ascii.split(" ") 886 | this.chunkLength = parseInt(bytes) 887 | return 888 | } 889 | 890 | invalidResponse(ascii) 891 | }, 892 | (payload: Uint8Array): SystemStats => { 893 | const decodedString = new TextDecoder().decode(payload) 894 | const rawStats = yaml.parse(decodedString) as Record 895 | const transformedStats = Object.fromEntries( 896 | Object.entries(rawStats).map(([key, value]) => [ 897 | camelCase(key), 898 | value 899 | ]) 900 | ) 901 | return transformedStats as unknown as SystemStats 902 | } 903 | ] 904 | ) 905 | 906 | /** 907 | * Lists all existing tubes 908 | * @returns Array of tube names 909 | */ 910 | listTubes = this.createCommandHandler<[], string[]>( 911 | () => new TextEncoder().encode(`list-tubes\r\n`), 912 | [ 913 | (buffer: Uint8Array) => { 914 | const ascii = validate(buffer, [DEADLINE_SOON, TIMED_OUT]) 915 | 916 | if (ascii.startsWith(OK)) { 917 | const [, bytes] = ascii.split(" ") 918 | this.chunkLength = parseInt(bytes) 919 | return 920 | } 921 | 922 | invalidResponse(ascii) 923 | }, 924 | (payload: Uint8Array): string[] => { 925 | const decodedString = new TextDecoder().decode(payload) 926 | return yaml.parse(decodedString) as string[] 927 | } 928 | ] 929 | ) 930 | 931 | /** 932 | * Lists tubes being watched by current connection 933 | * @returns Array of watched tube names 934 | */ 935 | listTubesWatched = this.createCommandHandler<[], string[]>( 936 | () => new TextEncoder().encode(`list-tubes-watched\r\n`), 937 | [ 938 | (buffer: Uint8Array) => { 939 | const ascii = validate(buffer, [DEADLINE_SOON, TIMED_OUT]) 940 | 941 | if (ascii.startsWith(OK)) { 942 | const [, bytes] = ascii.split(" ") 943 | this.chunkLength = parseInt(bytes) 944 | return 945 | } 946 | 947 | invalidResponse(ascii) 948 | }, 949 | (payload: Uint8Array): string[] => { 950 | const decodedString = new TextDecoder().decode(payload) 951 | return yaml.parse(decodedString) as string[] 952 | } 953 | ] 954 | ) 955 | 956 | /** 957 | * Returns the tube currently being used by client 958 | * @returns Name of tube being used 959 | */ 960 | listTubeUsed = this.createCommandHandler<[], string>( 961 | () => new TextEncoder().encode(`list-tube-used\r\n`), 962 | [ 963 | buffer => { 964 | const ascii = validate(buffer, [NOT_FOUND]) 965 | if (ascii.startsWith(USING)) { 966 | const [, tube] = ascii.split(" ") 967 | return tube 968 | } 969 | invalidResponse(ascii) 970 | } 971 | ] 972 | ) 973 | } 974 | 975 | export default JackdClient 976 | 977 | function validate( 978 | buffer: Uint8Array, 979 | additionalResponses: string[] = [] 980 | ): string { 981 | const ascii = new TextDecoder().decode(buffer) 982 | const errors = [OUT_OF_MEMORY, INTERNAL_ERROR, BAD_FORMAT, UNKNOWN_COMMAND] 983 | 984 | const errorCode = errors 985 | .concat(additionalResponses) 986 | .find(error => ascii.startsWith(error)) 987 | 988 | if (errorCode) throw new JackdError(errorCode as JackdErrorCode, ascii, ascii) 989 | 990 | return ascii 991 | } 992 | 993 | function invalidResponse(ascii: string) { 994 | throw new JackdError( 995 | JackdErrorCode.INVALID_RESPONSE, 996 | `Unexpected response: ${ascii}`, 997 | ascii 998 | ) 999 | } 1000 | 1001 | // Helper function to find index of subarray 1002 | function findIndex(array: Uint8Array, subarray: Uint8Array): number { 1003 | for (let i = 0; i <= array.length - subarray.length; i++) { 1004 | let found = true 1005 | for (let j = 0; j < subarray.length; j++) { 1006 | if (array[i + j] !== subarray[j]) { 1007 | found = false 1008 | break 1009 | } 1010 | } 1011 | if (found) return i 1012 | } 1013 | return -1 1014 | } 1015 | 1016 | export { JackdError, JackdErrorCode } from "./types" 1017 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type EventEmitter from "events" 2 | 3 | export const DELIMITER = "\r\n" 4 | 5 | export type JackdPayload = Uint8Array | string | object 6 | 7 | /** 8 | * Handler for processing command responses 9 | */ 10 | export type CommandHandler = ( 11 | chunk: Uint8Array, 12 | command: Uint8Array 13 | ) => T | Promise 14 | 15 | /** 16 | * Command execution state 17 | */ 18 | export type CommandExecution = { 19 | command: Uint8Array 20 | /** Handlers for processing command response */ 21 | handlers: CommandHandler[] 22 | emitter: EventEmitter 23 | written: boolean 24 | } 25 | 26 | /** 27 | * Options for putting a job into a tube 28 | */ 29 | export interface JackdPutOpts { 30 | /** Priority value between 0 and 2**32. Jobs with smaller priority values will be scheduled before jobs with larger priorities. 0 is most urgent. */ 31 | priority?: number 32 | /** Number of seconds to wait before putting the job in the ready queue. Job will be in "delayed" state during this time. Maximum is 2**32-1. */ 33 | delay?: number 34 | /** Time to run - number of seconds to allow a worker to run this job. Minimum is 1. If 0 is provided, server will use 1. Maximum is 2**32-1. */ 35 | ttr?: number 36 | } 37 | 38 | /** 39 | * Raw job data returned from reserveRaw 40 | */ 41 | export interface JackdJobRaw { 42 | /** Unique job ID for this instance of beanstalkd */ 43 | id: number 44 | /** Raw job payload as bytes */ 45 | payload: Uint8Array 46 | } 47 | 48 | /** 49 | * Job data with decoded string payload 50 | */ 51 | export interface JackdJob { 52 | /** Unique job ID for this instance of beanstalkd */ 53 | id: number 54 | /** Job payload decoded as UTF-8 string */ 55 | payload: string 56 | } 57 | 58 | /** 59 | * Stats for a specific job 60 | */ 61 | export interface JobStats { 62 | /** Job ID */ 63 | id: number 64 | /** Name of tube containing this job */ 65 | tube: string 66 | /** Current state of the job */ 67 | state: "ready" | "delayed" | "reserved" | "buried" 68 | /** Priority value set by put/release/bury */ 69 | pri: number 70 | /** Time in seconds since job creation */ 71 | age: number 72 | /** Seconds remaining until job is put in ready queue */ 73 | delay: number 74 | /** Time to run in seconds */ 75 | ttr: number 76 | /** Seconds until server puts job into ready queue (only meaningful if reserved/delayed) */ 77 | timeLeft: number 78 | /** Binlog file number containing this job (0 if binlog disabled) */ 79 | file: number 80 | /** Number of times job has been reserved */ 81 | reserves: number 82 | /** Number of times job has timed out during reservation */ 83 | timeouts: number 84 | /** Number of times job has been released */ 85 | releases: number 86 | /** Number of times job has been buried */ 87 | buries: number 88 | /** Number of times job has been kicked */ 89 | kicks: number 90 | } 91 | 92 | /** 93 | * Stats for a specific tube 94 | */ 95 | export interface TubeStats { 96 | /** Tube name */ 97 | name: string 98 | /** Number of ready jobs with priority < 1024 */ 99 | currentJobsUrgent: number 100 | /** Number of jobs in ready queue */ 101 | currentJobsReady: number 102 | /** Number of jobs reserved by all clients */ 103 | currentJobsReserved: number 104 | /** Number of delayed jobs */ 105 | currentJobsDelayed: number 106 | /** Number of buried jobs */ 107 | currentJobsBuried: number 108 | /** Total jobs created in this tube */ 109 | totalJobs: number 110 | /** Number of open connections using this tube */ 111 | currentUsing: number 112 | /** Number of connections waiting on reserve */ 113 | currentWaiting: number 114 | /** Number of connections watching this tube */ 115 | currentWatching: number 116 | /** Seconds tube is paused for */ 117 | pause: number 118 | /** Total delete commands for this tube */ 119 | cmdDelete: number 120 | /** Total pause-tube commands for this tube */ 121 | cmdPauseTube: number 122 | /** Seconds until tube is unpaused */ 123 | pauseTimeLeft: number 124 | } 125 | 126 | /** 127 | * System-wide statistics 128 | */ 129 | export interface SystemStats { 130 | /** Number of ready jobs with priority < 1024 */ 131 | currentJobsUrgent: number 132 | /** Number of jobs in ready queue */ 133 | currentJobsReady: number 134 | /** Number of jobs reserved by all clients */ 135 | currentJobsReserved: number 136 | /** Number of delayed jobs */ 137 | currentJobsDelayed: number 138 | /** Number of buried jobs */ 139 | currentJobsBuried: number 140 | /** Total put commands */ 141 | cmdPut: number 142 | /** Total peek commands */ 143 | cmdPeek: number 144 | /** Total peek-ready commands */ 145 | cmdPeekReady: number 146 | /** Total peek-delayed commands */ 147 | cmdPeekDelayed: number 148 | /** Total peek-buried commands */ 149 | cmdPeekBuried: number 150 | /** Total reserve commands */ 151 | cmdReserve: number 152 | /** Total reserve-with-timeout commands */ 153 | cmdReserveWithTimeout: number 154 | /** Total touch commands */ 155 | cmdTouch: number 156 | /** Total use commands */ 157 | cmdUse: number 158 | /** Total watch commands */ 159 | cmdWatch: number 160 | /** Total ignore commands */ 161 | cmdIgnore: number 162 | /** Total delete commands */ 163 | cmdDelete: number 164 | /** Total release commands */ 165 | cmdRelease: number 166 | /** Total bury commands */ 167 | cmdBury: number 168 | /** Total kick commands */ 169 | cmdKick: number 170 | /** Total stats commands */ 171 | cmdStats: number 172 | /** Total stats-job commands */ 173 | cmdStatsJob: number 174 | /** Total stats-tube commands */ 175 | cmdStatsTube: number 176 | /** Total list-tubes commands */ 177 | cmdListTubes: number 178 | /** Total list-tube-used commands */ 179 | cmdListTubeUsed: number 180 | /** Total list-tubes-watched commands */ 181 | cmdListTubesWatched: number 182 | /** Total pause-tube commands */ 183 | cmdPauseTube: number 184 | /** Total job timeouts */ 185 | jobTimeouts: number 186 | /** Total jobs created */ 187 | totalJobs: number 188 | /** Maximum job size in bytes */ 189 | maxJobSize: number 190 | /** Number of currently existing tubes */ 191 | currentTubes: number 192 | /** Number of currently open connections */ 193 | currentConnections: number 194 | /** Number of open connections that have issued at least one put */ 195 | currentProducers: number 196 | /** Number of open connections that have issued at least one reserve */ 197 | currentWorkers: number 198 | /** Number of connections waiting on reserve */ 199 | currentWaiting: number 200 | /** Total connections */ 201 | totalConnections: number 202 | /** Process ID of server */ 203 | pid: number 204 | /** Version string of server */ 205 | version: string 206 | /** User CPU time of process */ 207 | rusageUtime: number 208 | /** System CPU time of process */ 209 | rusageStime: number 210 | /** Seconds since server started */ 211 | uptime: number 212 | /** Index of oldest binlog file needed */ 213 | binlogOldestIndex: number 214 | /** Index of current binlog file */ 215 | binlogCurrentIndex: number 216 | /** Maximum binlog file size */ 217 | binlogMaxSize: number 218 | /** Total records written to binlog */ 219 | binlogRecordsWritten: number 220 | /** Total records migrated in binlog */ 221 | binlogRecordsMigrated: number 222 | /** Whether server is in drain mode */ 223 | draining: boolean 224 | /** Random ID of server process */ 225 | id: string 226 | /** Server hostname */ 227 | hostname: string 228 | /** Server OS version */ 229 | os: string 230 | /** Server machine architecture */ 231 | platform: string 232 | } 233 | 234 | /** 235 | * Options for releasing a job back to ready queue 236 | */ 237 | export interface JackdReleaseOpts { 238 | /** New priority to assign to job */ 239 | priority?: number 240 | /** Seconds to wait before putting job in ready queue */ 241 | delay?: number 242 | } 243 | 244 | /** 245 | * Options for pausing a tube 246 | */ 247 | export interface JackdPauseTubeOpts { 248 | /** Seconds to pause the tube for */ 249 | delay?: number 250 | } 251 | 252 | export type JackdPutArgs = [ 253 | payload: Uint8Array | string | object, 254 | options?: JackdPutOpts 255 | ] 256 | export type JackdReleaseArgs = [jobId: number, options?: JackdReleaseOpts] 257 | export type JackdPauseTubeArgs = [tubeId: string, options?: JackdPauseTubeOpts] 258 | export type JackdJobArgs = [jobId: number] 259 | export type JackdTubeArgs = [tubeId: string] 260 | export type JackdBuryArgs = [jobId: number, priority?: number] 261 | 262 | export type JackdArgs = 263 | | JackdPutArgs 264 | | JackdReleaseArgs 265 | | JackdPauseTubeArgs 266 | | JackdJobArgs 267 | | JackdTubeArgs 268 | | JackdBuryArgs 269 | | never[] 270 | | number[] 271 | | string[] 272 | | [jobId: number, priority?: number] 273 | 274 | /** 275 | * Client options 276 | */ 277 | export type JackdProps = { 278 | /** Whether to automatically connect to the server */ 279 | autoconnect?: boolean 280 | /** Hostname of beanstalkd server */ 281 | host?: string 282 | /** Port number, defaults to 11300 */ 283 | port?: number 284 | /** Whether to automatically reconnect on connection loss */ 285 | autoReconnect?: boolean 286 | /** Initial delay in ms between reconnection attempts */ 287 | initialReconnectDelay?: number 288 | /** Maximum delay in ms between reconnection attempts */ 289 | maxReconnectDelay?: number 290 | /** Maximum number of reconnection attempts (0 for infinite) */ 291 | maxReconnectAttempts?: number 292 | } 293 | 294 | /** 295 | * Standardized error codes for Jackd operations 296 | */ 297 | export enum JackdErrorCode { 298 | /** Server out of memory */ 299 | OUT_OF_MEMORY = "OUT_OF_MEMORY", 300 | /** Internal server error */ 301 | INTERNAL_ERROR = "INTERNAL_ERROR", 302 | /** Bad command format */ 303 | BAD_FORMAT = "BAD_FORMAT", 304 | /** Unknown command */ 305 | UNKNOWN_COMMAND = "UNKNOWN_COMMAND", 306 | /** Job body not properly terminated */ 307 | EXPECTED_CRLF = "EXPECTED_CRLF", 308 | /** Job larger than max-job-size */ 309 | JOB_TOO_BIG = "JOB_TOO_BIG", 310 | /** Server in drain mode */ 311 | DRAINING = "DRAINING", 312 | /** Timeout exceeded with no job */ 313 | TIMED_OUT = "TIMED_OUT", 314 | /** Reserved job TTR expiring */ 315 | DEADLINE_SOON = "DEADLINE_SOON", 316 | /** Resource not found */ 317 | NOT_FOUND = "NOT_FOUND", 318 | /** Cannot ignore only watched tube */ 319 | NOT_IGNORED = "NOT_IGNORED", 320 | /** Unexpected server response */ 321 | INVALID_RESPONSE = "INVALID_RESPONSE", 322 | /** Socket is not connected */ 323 | NOT_CONNECTED = "NOT_CONNECTED", 324 | /** Fatal connection error */ 325 | FATAL_CONNECTION_ERROR = "FATAL_CONNECTION_ERROR" 326 | } 327 | 328 | /** 329 | * Custom error class for Jackd operations 330 | */ 331 | export class JackdError extends Error { 332 | /** Error code indicating the type of error */ 333 | code: JackdErrorCode 334 | /** Raw response from server if available */ 335 | response?: string 336 | 337 | constructor(code: JackdErrorCode, message?: string, response?: string) { 338 | super(message || code) 339 | this.code = code 340 | this.response = response 341 | this.name = "JackdError" 342 | } 343 | } 344 | 345 | export const RESERVED = "RESERVED" 346 | export const INSERTED = "INSERTED" 347 | export const USING = "USING" 348 | export const TOUCHED = "TOUCHED" 349 | export const DELETED = "DELETED" 350 | export const BURIED = "BURIED" 351 | export const RELEASED = "RELEASED" 352 | export const NOT_FOUND = "NOT_FOUND" 353 | export const OUT_OF_MEMORY = "OUT_OF_MEMORY" 354 | export const INTERNAL_ERROR = "INTERNAL_ERROR" 355 | export const BAD_FORMAT = "BAD_FORMAT" 356 | export const UNKNOWN_COMMAND = "UNKNOWN_COMMAND" 357 | export const EXPECTED_CRLF = "EXPECTED_CRLF" 358 | export const JOB_TOO_BIG = "JOB_TOO_BIG" 359 | export const DRAINING = "DRAINING" 360 | export const TIMED_OUT = "TIMED_OUT" 361 | export const DEADLINE_SOON = "DEADLINE_SOON" 362 | export const FOUND = "FOUND" 363 | export const WATCHING = "WATCHING" 364 | export const NOT_IGNORED = "NOT_IGNORED" 365 | export const KICKED = "KICKED" 366 | export const PAUSED = "PAUSED" 367 | export const OK = "OK" 368 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "outDir": "./dist/types" 8 | }, 9 | "include": ["src/index.ts"], 10 | "exclude": [ 11 | "src/**/*.test.ts", 12 | "src/**/*.spec.ts", 13 | "build.ts", 14 | "eslint.config.mjs" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2022"], 4 | "target": "ES2022", 5 | "module": "ESNext", 6 | "moduleDetection": "force", 7 | "allowJs": true, 8 | 9 | // Bundler mode 10 | "moduleResolution": "bundler", 11 | "verbatimModuleSyntax": true, 12 | "noEmit": true, 13 | 14 | "types": ["node", "bun-types"], 15 | 16 | // Best practices 17 | "strict": true, 18 | "skipLibCheck": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["src/**/*", "build.ts", "eslint.config.mjs"] 22 | } 23 | --------------------------------------------------------------------------------