From 34ca0fe17d3c074981598fb31b6dce5f628837dd Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sat, 16 May 2026 00:51:54 +0200 Subject: [PATCH] feat: add reusable jobqueue library Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/publish.yml | 36 + .gitignore | 7 + LICENSE | 21 + README.md | 253 ++ package-lock.json | 3394 +++++++++++++++++++++++++++ package.json | 62 + src/JobQueue.ts | 721 ++++++ src/events/EventBus.ts | 47 + src/events/SseSerializer.ts | 29 + src/index.ts | 38 + src/processor/PhaseRunner.ts | 143 ++ src/processor/WorkerPool.ts | 58 + src/retention/RetentionScheduler.ts | 43 + src/retry/RetryStrategy.ts | 82 + src/storage/SqliteStorage.ts | 580 +++++ src/types.ts | 216 ++ src/util/errors.ts | 6 + src/util/id.ts | 5 + src/webhook/WebhookDispatcher.ts | 143 ++ tests/JobQueue.test.ts | 135 ++ tests/RetentionScheduler.test.ts | 28 + tests/RetryStrategy.test.ts | 72 + tests/SqliteStorage.test.ts | 76 + tests/SseSerializer.test.ts | 25 + tests/WebhookDispatcher.test.ts | 75 + tests/WorkerPool.test.ts | 26 + tests/helpers.ts | 34 + tsconfig.json | 25 + tsup.config.ts | 11 + vitest.config.ts | 14 + 30 files changed, 6405 insertions(+) create mode 100644 .gitea/workflows/publish.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/JobQueue.ts create mode 100644 src/events/EventBus.ts create mode 100644 src/events/SseSerializer.ts create mode 100644 src/index.ts create mode 100644 src/processor/PhaseRunner.ts create mode 100644 src/processor/WorkerPool.ts create mode 100644 src/retention/RetentionScheduler.ts create mode 100644 src/retry/RetryStrategy.ts create mode 100644 src/storage/SqliteStorage.ts create mode 100644 src/types.ts create mode 100644 src/util/errors.ts create mode 100644 src/util/id.ts create mode 100644 src/webhook/WebhookDispatcher.ts create mode 100644 tests/JobQueue.test.ts create mode 100644 tests/RetentionScheduler.test.ts create mode 100644 tests/RetryStrategy.test.ts create mode 100644 tests/SqliteStorage.test.ts create mode 100644 tests/SseSerializer.test.ts create mode 100644 tests/WebhookDispatcher.test.ts create mode 100644 tests/WorkerPool.test.ts create mode 100644 tests/helpers.ts create mode 100644 tsconfig.json create mode 100644 tsup.config.ts create mode 100644 vitest.config.ts diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..78b3d6c --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,36 @@ +name: publish + +on: + push: + tags: + - 'v*' + +jobs: + test-and-publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://git.sal.giize.com/api/packages/mozempk/npm/ + + - name: Install deps + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + + - name: Build + run: npm run build + + - name: Publish + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITEA_NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c799dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.db +*.db-wal +*.db-shm +.env +coverage/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f0efad --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Giancarmine Salucci + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ca6ad7 --- /dev/null +++ b/README.md @@ -0,0 +1,253 @@ +# jobqueue + +Framework-agnostic async job queue for Node.js with: + +- **SQLite persistence** via `better-sqlite3` + WAL mode +- **Multi-phase pipelines** with per-phase progress and phase result passing +- **Concurrency control** with a worker pool +- **Automatic retry** with fixed, linear, or exponential backoff +- **SSE helpers** for progress/completion streaming +- **Outbound webhooks** with optional HMAC-SHA256 signing +- **Retention cleanup** for stale and expired jobs +- **ESM-only**, **Node 20+** + +## Install + +```bash +npm install jobqueue --registry=https://git.sal.giize.com/api/packages/mozempk/npm/ +``` + +To make installs/publishes use Gitea by default: + +```ini +registry=https://git.sal.giize.com/api/packages/mozempk/npm/ +//git.sal.giize.com/api/packages/mozempk/npm/:_authToken=${NODE_AUTH_TOKEN} +``` + +## Quick start + +```ts +import { JobQueue } from 'jobqueue'; + +const queue = new JobQueue<{ url: string }>({ + dbPath: './data/jobs.db', + phases: ['download', 'process', 'upload'], + concurrency: 2, + retry: { + maxAttempts: 3, + strategy: 'exponential', + baseDelayMs: 1_000, + maxDelayMs: 30_000, + classifyError: async (error) => { + if (error instanceof Error && error.message.includes('ECONNREFUSED')) { + return 'recoverable'; + } + + return 'fatal'; + }, + }, + webhook: { + url: 'https://example.com/hooks/jobs', + events: ['job:completed', 'job:failed'], + secret: process.env.JOBQUEUE_WEBHOOK_SECRET, + }, + retention: { + staleAfterMs: 7 * 24 * 60 * 60 * 1000, + deleteAfterMs: 30 * 24 * 60 * 60 * 1000, + onDelete: async (job) => { + console.log(`Deleting ${job.id}`); + }, + }, +}); + +queue.handle('download', async (job, ctx) => { + await ctx.progress(10, 'Downloading source'); + return { filePath: `/tmp/${job.id}.mp4` }; +}); + +queue.handle('process', async (_job, ctx) => { + const download = ctx.phaseResult<{ filePath: string }>('download'); + await ctx.progress(50, 'Processing file'); + return { outputPath: `${download?.filePath}.json` }; +}); + +queue.handle('upload', async (_job, ctx) => { + const processed = ctx.phaseResult<{ outputPath: string }>('process'); + await ctx.progress(100, 'Uploading result'); + return { uploaded: Boolean(processed?.outputPath) }; +}); + +queue.on('job:completed', (job) => { + console.log('completed', job.id, job.phaseResults); +}); + +const jobId = await queue.enqueue({ + url: 'https://example.com/video', +}); +``` + +## API + +### `new JobQueue(config)` + +```ts +interface QueueConfig { + dbPath: string; + phases: readonly string[]; + concurrency?: number; + retry?: RetryConfig; + retention?: RetentionConfig; + webhook?: WebhookConfig; + shutdownTimeoutMs?: number; +} +``` + +### `queue.handle(phaseName, handler)` + +Register one handler per phase. + +```ts +type PhaseHandler = ( + job: JobRecord, + context: PhaseContext, +) => Promise | JsonValue | undefined; +``` + +### `queue.enqueue(data, options?)` + +Creates a new job and returns its ID. + +```ts +await queue.enqueue( + { url: 'https://example.com/video' }, + { + scheduledAt: new Date(Date.now() + 5_000), + maxAttempts: 5, + webhookUrl: 'https://example.com/job-specific-hook', + }, +); +``` + +### `queue.getJob(id)` / `queue.listJobs(options)` + +Inspect persisted jobs. + +### `queue.retry(id, options?)` + +Manual retry. By default it restarts from the first phase. + +```ts +await queue.retry(jobId, { fromStart: true }); +``` + +### `queue.cancel(id)` + +Cooperative cancellation. Active handlers receive an `AbortSignal` at `ctx.signal`. + +### `queue.createEventStream(options?)` + +Returns a web `ReadableStream` ready to plug into Express, Hono, SvelteKit, Fastify, or plain Node responses. + +```ts +const stream = queue.createEventStream({ + jobId, + includeSnapshot: true, +}); +``` + +Use headers from `SseSerializer.headers()`. + +### `queue.shutdown()` + +Stops retention timers, waits for active jobs, then closes SQLite cleanly. + +## Retry behavior + +`maxAttempts` includes the first attempt. + +- `maxAttempts: 1` → never auto-retry +- `maxAttempts: 3` → first run + 2 retries + +Error classification decides whether a failure is retryable: + +```ts +retry: { + maxAttempts: 3, + classifyError: async (error) => { + if (error instanceof Error && /timeout|econnrefused/i.test(error.message)) { + return 'recoverable'; + } + + return 'fatal'; + }, +} +``` + +## Webhooks + +Supported outbound events: + +- `job:completed` +- `job:failed` +- `job:retrying` +- `job:cancelled` +- `job:stale` + +Payload shape: + +```json +{ + "event": "job:completed", + "emittedAt": "2026-05-15T22:00:00.000Z", + "job": { + "id": "..." + } +} +``` + +If `secret` is configured, jobqueue sends: + +```text +X-JobQueue-Event: job:completed +X-JobQueue-Signature: +``` + +## SSE events + +Stream payloads are emitted with these event names: + +- `snapshot` +- `job:enqueued` +- `job:started` +- `job:progress` +- `job:phase:completed` +- `job:completed` +- `job:failed` +- `job:retrying` +- `job:cancelled` +- `job:stale` +- `job:deleted` +- `job:webhook:delivered` +- `job:webhook:failed` +- `ping` + +## Development + +```bash +npm install +npm run lint +npm test +npm run build +``` + +## Publish from Gitea Actions + +Create repo secret: + +- `GITEA_NPM_TOKEN` → token with package publish permissions + +Publishing flow: + +1. Push code to `mozempk/jobqueue` +2. Create tag: `git tag v0.1.0 && git push origin v0.1.0` +3. Gitea Actions runs tests, build, and `npm publish` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..33f78ac --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3394 @@ +{ + "name": "jobqueue", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jobqueue", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "better-sqlite3": "^12.2.0", + "p-limit": "^6.2.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22.15.0", + "@vitest/coverage-v8": "^3.1.0", + "tsup": "^8.4.0", + "typescript": "^5.8.0", + "vitest": "^3.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..75c1c0c --- /dev/null +++ b/package.json @@ -0,0 +1,62 @@ +{ + "name": "jobqueue", + "version": "0.1.0", + "description": "Framework-agnostic async job queue with multi-phase pipelines, SSE helpers, webhooks, retry strategies, and SQLite persistence.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "ssh://git@git.sal.giize.com:2222/mozempk/jobqueue.git" + }, + "homepage": "https://git.sal.giize.com/mozempk/jobqueue", + "publishConfig": { + "registry": "https://git.sal.giize.com/api/packages/mozempk/npm/" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "tsc --noEmit", + "prepublishOnly": "npm run lint && npm run test && npm run build" + }, + "keywords": [ + "job-queue", + "async", + "pipeline", + "worker", + "sse", + "webhook", + "sqlite", + "retry", + "concurrency" + ], + "author": "Giancarmine Salucci ", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "better-sqlite3": "^12.2.0", + "p-limit": "^6.2.0" + }, + "devDependencies": { + "@vitest/coverage-v8": "^3.1.0", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22.15.0", + "tsup": "^8.4.0", + "typescript": "^5.8.0", + "vitest": "^3.1.0" + } +} diff --git a/src/JobQueue.ts b/src/JobQueue.ts new file mode 100644 index 0000000..3a50e83 --- /dev/null +++ b/src/JobQueue.ts @@ -0,0 +1,721 @@ +import { CancellationError } from './util/errors.js'; +import { TypedEventBus } from './events/EventBus.js'; +import { SseSerializer } from './events/SseSerializer.js'; +import { WorkerPool } from './processor/WorkerPool.js'; +import { PhaseRunner } from './processor/PhaseRunner.js'; +import { RetryStrategy } from './retry/RetryStrategy.js'; +import { SqliteStorage } from './storage/SqliteStorage.js'; +import { RetentionScheduler } from './retention/RetentionScheduler.js'; +import { WebhookDispatcher } from './webhook/WebhookDispatcher.js'; +import { createJobId } from './util/id.js'; + +import type { + EnqueueOptions, + JobData, + JobFailure, + JobPhaseState, + JobProgressEvent, + JobQueueEvents, + JobRecord, + ListJobsOptions, + PhaseHandler, + QueueConfig, + QueueStreamEvent, + RetryOptions, + StreamOptions, + WebhookDispatchError, + WebhookEventName, +} from './types.js'; + +const DEFAULT_CONCURRENCY = 1; +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000; + +function now(): string { + return new Date().toISOString(); +} + +function createInitialPhases(phaseNames: readonly string[]): JobPhaseState[] { + return phaseNames.map((name) => ({ + name, + status: 'pending', + progress: 0, + message: null, + startedAt: null, + completedAt: null, + error: null, + })); +} + +function clonePhases(phases: JobPhaseState[]): JobPhaseState[] { + return phases.map((phase) => ({ ...phase })); +} + +function normalizeQueueConfig(config: QueueConfig): QueueConfig { + const phases = [...new Set(config.phases)]; + + if (phases.length === 0) { + throw new Error('QueueConfig.phases must contain at least one phase'); + } + + return { + ...config, + phases, + concurrency: config.concurrency ?? DEFAULT_CONCURRENCY, + shutdownTimeoutMs: config.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS, + }; +} + +export class JobQueue { + private readonly config: QueueConfig; + private readonly storage: SqliteStorage; + private readonly events = new TypedEventBus>(); + private readonly retryStrategy: RetryStrategy; + private readonly workerPool: WorkerPool; + private readonly handlers = new Map>(); + private readonly serializer = new SseSerializer(); + private readonly controllers = new Map(); + private readonly webhookDispatcher?: WebhookDispatcher; + private readonly retentionScheduler?: RetentionScheduler; + private wakeupTimer: NodeJS.Timeout | null = null; + private closed = false; + private pumping = false; + private repumpRequested = false; + + public constructor(queueConfig: QueueConfig) { + this.config = normalizeQueueConfig(queueConfig); + this.storage = new SqliteStorage(this.config.dbPath); + this.retryStrategy = new RetryStrategy(this.config.retry); + this.workerPool = new WorkerPool(this.config.concurrency ?? DEFAULT_CONCURRENCY); + this.webhookDispatcher = this.config.webhook + ? new WebhookDispatcher(this.config.webhook) + : undefined; + + this.storage.resetActiveJobs('Interrupted by process restart'); + + if (this.config.retention) { + this.retentionScheduler = new RetentionScheduler(this.config.retention, { + markStale: async (cutoffIso) => this.markStaleJobs(cutoffIso), + deleteStale: async (cutoffIso) => this.deleteStaleJobs(cutoffIso), + }); + this.retentionScheduler.start(); + } + + this.requestPump(); + } + + public handle(phaseName: string, handler: PhaseHandler): this { + if (!this.config.phases.includes(phaseName)) { + throw new Error(`Phase "${phaseName}" is not defined in queue config`); + } + + this.handlers.set(phaseName, handler); + return this; + } + + public on & string>( + event: TName, + listener: (...args: JobQueueEvents[TName]) => void, + ): () => void { + return this.events.on(event, listener); + } + + public once & string>( + event: TName, + listener: (...args: JobQueueEvents[TName]) => void, + ): () => void { + return this.events.once(event, listener); + } + + public async enqueue(data: TData, options: EnqueueOptions = {}): Promise { + this.ensureOpen(); + const id = options.id ?? createJobId(); + const job = this.storage.createJob( + id, + data, + createInitialPhases(this.config.phases), + options, + options.maxAttempts ?? this.retryStrategy.defaultMaxAttempts(), + ); + + this.events.emit('job:enqueued', job); + this.requestPump(); + + return id; + } + + public getJob(id: string): JobRecord | null { + return this.storage.getJob(id); + } + + public listJobs(options: ListJobsOptions = {}): JobRecord[] { + return this.storage.listJobs(options); + } + + public async retry(id: string, options: RetryOptions = {}): Promise> { + this.ensureOpen(); + const existing = this.requireJob(id); + + if (!['failed', 'cancelled', 'stale'].includes(existing.status)) { + throw new Error(`Job ${id} cannot be retried from status ${existing.status}`); + } + + const phases = + options.fromStart === false + ? clonePhases(existing.phases).map((phase) => + phase.status === 'completed' + ? phase + : { + ...phase, + status: 'pending', + progress: 0, + message: null, + completedAt: null, + error: null, + }, + ) + : createInitialPhases(this.config.phases); + const retried = this.storage.resetForRetry( + id, + phases, + existing.maxAttempts, + options.scheduledAt instanceof Date ? options.scheduledAt.toISOString() : options.scheduledAt ?? null, + ); + + this.events.emit('job:enqueued', retried); + this.requestPump(); + + return retried; + } + + public async cancel(id: string): Promise> { + const job = this.requireJob(id); + + if (!['pending', 'active'].includes(job.status)) { + return job; + } + + const phases = clonePhases(job.phases); + const activePhaseIndex = phases.findIndex((phase) => phase.status === 'active'); + if (activePhaseIndex >= 0) { + phases[activePhaseIndex] = { + ...phases[activePhaseIndex], + status: 'cancelled', + completedAt: now(), + }; + } + + const controller = this.controllers.get(id); + controller?.abort(new CancellationError()); + + const cancelled = this.storage.cancelJob(id, phases); + this.events.emit('job:cancelled', cancelled); + void this.dispatchWebhook('job:cancelled', cancelled); + this.requestPump(); + + return cancelled; + } + + public createEventStream(options: StreamOptions = {}): ReadableStream { + const encoder = new TextEncoder(); + const keepAliveMs = options.keepAliveMs ?? 30_000; + let cleanup = () => {}; + + return new ReadableStream({ + start: (controller) => { + if (options.includeSnapshot !== false) { + if (options.jobId) { + const job = this.getJob(options.jobId); + if (job) { + this.enqueueStreamEvent(controller, encoder, 'snapshot', { + type: 'snapshot', + jobId: job.id, + job, + timestamp: now(), + }); + } + } else { + for (const job of this.listJobs({ limit: 100 })) { + this.enqueueStreamEvent(controller, encoder, 'snapshot', { + type: 'snapshot', + jobId: job.id, + job, + timestamp: now(), + }); + } + } + } + + const unsubscribers = [ + this.on('job:enqueued', (job) => this.pushJobEvent(controller, encoder, 'job:enqueued', job, options.jobId)), + this.on('job:started', (job) => this.pushJobEvent(controller, encoder, 'job:started', job, options.jobId)), + this.on('job:progress', (job, progress) => { + if (!this.matchesStreamJob(options.jobId, job.id)) { + return; + } + + this.enqueueStreamEvent(controller, encoder, 'job:progress', { + type: 'job:progress', + jobId: job.id, + job, + progress, + timestamp: now(), + }); + }), + this.on('job:phase:completed', (job, phase) => { + if (!this.matchesStreamJob(options.jobId, job.id)) { + return; + } + + this.enqueueStreamEvent(controller, encoder, 'job:phase:completed', { + type: 'job:phase:completed', + jobId: job.id, + job, + phase, + timestamp: now(), + }); + }), + this.on('job:completed', (job) => this.pushJobEvent(controller, encoder, 'job:completed', job, options.jobId)), + this.on('job:failed', (job, failure) => { + if (!this.matchesStreamJob(options.jobId, job.id)) { + return; + } + + this.enqueueStreamEvent(controller, encoder, 'job:failed', { + type: 'job:failed', + jobId: job.id, + job, + failure, + timestamp: now(), + }); + }), + this.on('job:retrying', (job, retry) => { + if (!this.matchesStreamJob(options.jobId, job.id)) { + return; + } + + this.enqueueStreamEvent(controller, encoder, 'job:retrying', { + type: 'job:retrying', + jobId: job.id, + job, + retry, + timestamp: now(), + }); + }), + this.on('job:cancelled', (job) => this.pushJobEvent(controller, encoder, 'job:cancelled', job, options.jobId)), + this.on('job:stale', (job) => this.pushJobEvent(controller, encoder, 'job:stale', job, options.jobId)), + this.on('job:deleted', (jobId) => { + if (!this.matchesStreamJob(options.jobId, jobId)) { + return; + } + + this.enqueueStreamEvent(controller, encoder, 'job:deleted', { + type: 'job:deleted', + deletedJobId: jobId, + jobId, + timestamp: now(), + }); + }), + this.on('job:webhook:delivered', (job, webhook) => { + if (!this.matchesStreamJob(options.jobId, job.id)) { + return; + } + + this.enqueueStreamEvent(controller, encoder, 'job:webhook:delivered', { + type: 'job:webhook:delivered', + jobId: job.id, + job, + webhook, + timestamp: now(), + }); + }), + this.on('job:webhook:failed', (job, webhook) => { + if (!this.matchesStreamJob(options.jobId, job.id)) { + return; + } + + this.enqueueStreamEvent(controller, encoder, 'job:webhook:failed', { + type: 'job:webhook:failed', + jobId: job.id, + job, + webhook, + timestamp: now(), + }); + }), + ]; + + const keepAlive = setInterval(() => { + this.enqueueStreamEvent(controller, encoder, 'ping', { + type: 'ping', + timestamp: now(), + }); + }, keepAliveMs); + keepAlive.unref?.(); + + cleanup = () => { + clearInterval(keepAlive); + for (const unsubscribe of unsubscribers) { + unsubscribe(); + } + }; + }, + cancel: () => { + cleanup(); + return undefined; + }, + }); + } + + public async shutdown(timeoutMs = this.config.shutdownTimeoutMs): Promise { + if (this.closed) { + return; + } + + this.closed = true; + this.retentionScheduler?.stop(); + + if (this.wakeupTimer) { + clearTimeout(this.wakeupTimer); + this.wakeupTimer = null; + } + + await this.workerPool.drain(timeoutMs); + this.events.removeAllListeners(); + this.storage.close(); + } + + private async pump(): Promise { + if (this.closed) { + return; + } + + if (this.pumping) { + this.repumpRequested = true; + return; + } + + this.pumping = true; + + try { + do { + this.repumpRequested = false; + + while (this.workerPool.hasCapacity()) { + const candidates = this.storage.listRunnableJobs(this.workerPool.availableSlots); + + if (candidates.length === 0) { + break; + } + + let dispatched = 0; + + for (const candidate of candidates) { + if (!this.workerPool.hasCapacity()) { + break; + } + + if (!this.storage.claimPendingJob(candidate.id)) { + continue; + } + + const claimed = this.requireJob(candidate.id); + this.events.emit('job:started', claimed); + this.workerPool + .run(async () => this.processJob(claimed.id)) + .finally(() => { + this.requestPump(); + }); + dispatched += 1; + } + + if (dispatched === 0) { + break; + } + } + + this.scheduleWakeup(); + } while (this.repumpRequested); + } finally { + this.pumping = false; + } + } + + private requestPump(): void { + if (this.closed) { + return; + } + + void this.pump(); + } + + private scheduleWakeup(): void { + if (this.wakeupTimer) { + clearTimeout(this.wakeupTimer); + this.wakeupTimer = null; + } + + const nextScheduledAt = this.storage.getNextScheduledAt(); + if (!nextScheduledAt) { + return; + } + + const delay = Math.max(new Date(nextScheduledAt).getTime() - Date.now(), 0); + this.wakeupTimer = setTimeout(() => { + this.wakeupTimer = null; + this.requestPump(); + }, delay); + this.wakeupTimer.unref?.(); + } + + private async processJob(jobId: string): Promise { + const job = this.requireJob(jobId); + const controller = new AbortController(); + this.controllers.set(jobId, controller); + + try { + const runner = new PhaseRunner({ + handlers: this.handlers, + phases: this.config.phases, + onPhaseStarted: async (phaseName, phases) => + this.storage.saveProgress(jobId, phaseName, phases, this.requireJob(jobId).progress, null), + onProgress: async (phaseName, phases, phaseProgress, overallProgress, message, details) => { + const updated = this.storage.saveProgress(jobId, phaseName, phases, overallProgress, message ?? null); + const progressEvent: JobProgressEvent = { + jobId, + phase: phaseName, + phaseProgress, + overallProgress, + message: message ?? null, + timestamp: now(), + ...(details !== undefined ? { details } : {}), + }; + + this.events.emit('job:progress', updated, progressEvent); + }, + onPhaseCompleted: async (phaseName, phases, phaseResults, overallProgress) => { + const updated = this.storage.savePhaseCompletion( + jobId, + phases, + phaseResults, + null, + overallProgress, + null, + ); + const completedPhase = updated.phases.find((phase) => phase.name === phaseName); + if (!completedPhase) { + throw new Error(`Completed phase ${phaseName} not found`); + } + + this.events.emit('job:phase:completed', updated, completedPhase); + return updated; + }, + onCancelled: async (phaseName, phases) => { + const activePhases = clonePhases(phases); + if (phaseName) { + const active = activePhases.find((phase) => phase.name === phaseName); + if (active) { + active.status = 'cancelled'; + active.completedAt = now(); + } + } + + const cancelled = this.storage.cancelJob(jobId, activePhases); + this.events.emit('job:cancelled', cancelled); + void this.dispatchWebhook('job:cancelled', cancelled); + }, + }); + + const result = await runner.run(job, controller.signal); + const latest = this.requireJob(jobId); + + if (latest.status === 'cancelled') { + return; + } + + const completed = this.storage.completeJob(jobId, result.phases, result.phaseResults); + this.events.emit('job:completed', completed); + void this.dispatchWebhook('job:completed', completed); + } catch (error) { + if (error instanceof CancellationError) { + const current = this.requireJob(jobId); + if (current.status !== 'cancelled') { + const cancelled = this.storage.cancelJob(jobId, clonePhases(current.phases)); + this.events.emit('job:cancelled', cancelled); + void this.dispatchWebhook('job:cancelled', cancelled); + } + + return; + } + + await this.handleFailure(jobId, error); + } finally { + this.controllers.delete(jobId); + } + } + + private async handleFailure(jobId: string, error: unknown): Promise { + const current = this.requireJob(jobId); + const recoverability = await this.retryStrategy.shouldRetry(error, current); + const phaseName = current.currentPhase; + const phases = clonePhases(current.phases); + const activePhase = phaseName ? phases.find((phase) => phase.name === phaseName) : undefined; + + if (activePhase) { + activePhase.error = error instanceof Error ? error.message : String(error); + } + + const failure: JobFailure = { + message: error instanceof Error ? error.message : String(error), + phase: phaseName, + recoverable: recoverability.disposition === 'recoverable', + timestamp: now(), + attempt: current.retryCount + 1, + }; + + if (recoverability.retry && activePhase) { + activePhase.status = 'pending'; + activePhase.progress = 0; + activePhase.message = null; + activePhase.completedAt = null; + + const nextRunAt = new Date(Date.now() + recoverability.delayMs).toISOString(); + const pending = this.storage.scheduleRetry( + jobId, + phases, + current.progress, + failure, + current.retryCount + 1, + nextRunAt, + ); + const retry = { + jobId, + phase: phaseName, + attempt: pending.retryCount, + delayMs: recoverability.delayMs, + nextRunAt, + timestamp: now(), + }; + + this.events.emit('job:retrying', pending, retry); + void this.dispatchWebhook('job:retrying', pending); + this.requestPump(); + return; + } + + if (activePhase) { + activePhase.status = 'failed'; + activePhase.completedAt = now(); + } + + const failed = this.storage.failJob(jobId, phases, failure); + this.events.emit('job:failed', failed, failure); + void this.dispatchWebhook('job:failed', failed); + } + + private async markStaleJobs(cutoffIso: string): Promise[]> { + const staleJobs = this.storage.markStaleJobs(cutoffIso); + + for (const job of staleJobs) { + if (this.config.retention?.onStale) { + await this.config.retention.onStale(job); + } + + this.events.emit('job:stale', job); + void this.dispatchWebhook('job:stale', job); + } + + return staleJobs; + } + + private async deleteStaleJobs(cutoffIso: string): Promise[]> { + const deletedJobs = this.storage.deleteStaleJobs(cutoffIso); + + for (const job of deletedJobs) { + if (this.config.retention?.onDelete) { + await this.config.retention.onDelete(job); + } + + this.events.emit('job:deleted', job.id); + } + + return deletedJobs; + } + + private async dispatchWebhook(event: WebhookEventName, job: JobRecord): Promise { + if (!this.webhookDispatcher?.supports(event, job)) { + return; + } + + try { + const result = await this.webhookDispatcher.dispatch(event, job); + this.storage.markWebhookSent(job.id); + const refreshed = this.requireJob(job.id); + this.events.emit('job:webhook:delivered', refreshed, result); + } catch (error) { + const dispatchError = + error instanceof Error && 'dispatchError' in error + ? ((error as { dispatchError?: WebhookDispatchError }).dispatchError ?? { + event, + jobId: job.id, + message: error.message, + finalAttempt: 1, + }) + : { + event, + jobId: job.id, + message: error instanceof Error ? error.message : String(error), + finalAttempt: 1, + }; + + const refreshed = this.requireJob(job.id); + this.events.emit('job:webhook:failed', refreshed, dispatchError); + } + } + + private pushJobEvent( + controller: ReadableStreamDefaultController, + encoder: { encode: (input?: string) => Uint8Array }, + type: Exclude, + job: JobRecord, + jobIdFilter?: string, + ): void { + if (!this.matchesStreamJob(jobIdFilter, job.id)) { + return; + } + + this.enqueueStreamEvent(controller, encoder, type, { + type, + jobId: job.id, + job, + timestamp: now(), + }); + } + + private enqueueStreamEvent( + controller: ReadableStreamDefaultController, + encoder: { encode: (input?: string) => Uint8Array }, + eventName: string, + payload: QueueStreamEvent, + ): void { + controller.enqueue(encoder.encode(this.serializer.event(eventName, payload))); + } + + private matchesStreamJob(jobIdFilter: string | undefined, jobId: string): boolean { + return !jobIdFilter || jobIdFilter === jobId; + } + + private requireJob(id: string): JobRecord { + const job = this.storage.getJob(id); + + if (!job) { + throw new Error(`Job ${id} not found`); + } + + return job; + } + + private ensureOpen(): void { + if (this.closed) { + throw new Error('JobQueue is shut down'); + } + } +} diff --git a/src/events/EventBus.ts b/src/events/EventBus.ts new file mode 100644 index 0000000..98e708c --- /dev/null +++ b/src/events/EventBus.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'node:events'; + +type ExtractArgs = TValue extends unknown[] ? TValue : never; + +export class TypedEventBus { + private readonly emitter = new EventEmitter(); + + public on( + event: TName, + listener: (...args: ExtractArgs) => void, + ): () => void { + this.emitter.on(event, listener as (...args: unknown[]) => void); + return () => { + this.off(event, listener); + }; + } + + public once( + event: TName, + listener: (...args: ExtractArgs) => void, + ): () => void { + const wrapped = (...args: ExtractArgs) => { + this.off(event, wrapped); + listener(...args); + }; + + return this.on(event, wrapped); + } + + public off( + event: TName, + listener: (...args: ExtractArgs) => void, + ): void { + this.emitter.off(event, listener as (...args: unknown[]) => void); + } + + public emit( + event: TName, + ...args: ExtractArgs + ): void { + this.emitter.emit(event, ...(args as unknown[])); + } + + public removeAllListeners(): void { + this.emitter.removeAllListeners(); + } +} diff --git a/src/events/SseSerializer.ts b/src/events/SseSerializer.ts new file mode 100644 index 0000000..78a5edf --- /dev/null +++ b/src/events/SseSerializer.ts @@ -0,0 +1,29 @@ +import type { QueueStreamEvent } from '../types.js'; + +export class SseSerializer { + public static headers(): Record { + return { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }; + } + + public event(event: string, payload: TEvent, id?: string): string { + const lines: string[] = []; + + if (id) { + lines.push(`id: ${id}`); + } + + lines.push(`event: ${event}`); + lines.push(`data: ${JSON.stringify(payload)}`); + + return `${lines.join('\n')}\n\n`; + } + + public comment(comment: string): string { + return `: ${comment}\n\n`; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6a42304 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,38 @@ +export { JobQueue } from './JobQueue.js'; +export { TypedEventBus } from './events/EventBus.js'; +export { SseSerializer } from './events/SseSerializer.js'; +export { PhaseRunner } from './processor/PhaseRunner.js'; +export { WorkerPool } from './processor/WorkerPool.js'; +export { RetentionScheduler } from './retention/RetentionScheduler.js'; +export { RetryStrategy } from './retry/RetryStrategy.js'; +export { SqliteStorage } from './storage/SqliteStorage.js'; +export { WebhookDispatcher } from './webhook/WebhookDispatcher.js'; +export { CancellationError } from './util/errors.js'; +export { createJobId } from './util/id.js'; + +export type { + EnqueueOptions, + ErrorDisposition, + JobData, + JobFailure, + JobPhaseState, + JobProgressEvent, + JobQueueEvents, + JobRecord, + JobStatus, + JsonValue, + ListJobsOptions, + PhaseContext, + PhaseHandler, + QueueConfig, + QueueStreamEvent, + RetentionConfig, + RetryBackoffStrategy, + RetryConfig, + RetryOptions, + StreamOptions, + WebhookConfig, + WebhookDispatchError, + WebhookDispatchResult, + WebhookEventName, +} from './types.js'; diff --git a/src/processor/PhaseRunner.ts b/src/processor/PhaseRunner.ts new file mode 100644 index 0000000..f396652 --- /dev/null +++ b/src/processor/PhaseRunner.ts @@ -0,0 +1,143 @@ +import type { + JobData, + JobPhaseState, + JobRecord, + JsonValue, + PhaseContext, + PhaseHandler, +} from '../types.js'; + +interface PhaseRunnerOptions { + handlers: Map>; + phases: readonly string[]; + onProgress: ( + phaseName: string, + phases: JobPhaseState[], + phaseProgress: number, + overallProgress: number, + message: string | undefined, + details: JsonValue | undefined, + ) => Promise; + onPhaseStarted: (phaseName: string, phases: JobPhaseState[]) => Promise>; + onPhaseCompleted: ( + phaseName: string, + phases: JobPhaseState[], + phaseResults: Record, + overallProgress: number, + ) => Promise>; + onCancelled: (phaseName: string | null, phases: JobPhaseState[]) => Promise; +} + +export class PhaseRunner { + public constructor(private readonly options: PhaseRunnerOptions) {} + + public async run( + job: JobRecord, + signal: AbortSignal, + ): Promise<{ phases: JobPhaseState[]; phaseResults: Record }> { + const phases = job.phases.map((phase) => ({ ...phase })); + const phaseResults = { ...job.phaseResults }; + let currentJob = job; + + for (const [phaseIndex, phaseName] of this.options.phases.entries()) { + const phaseState = phases[phaseIndex]; + + if (!phaseState) { + throw new Error(`Missing phase state for ${phaseName}`); + } + + if (phaseState.status === 'completed') { + continue; + } + + if (signal.aborted) { + phaseState.status = 'cancelled'; + phaseState.completedAt = new Date().toISOString(); + await this.options.onCancelled(phaseName, phases); + return { phases, phaseResults }; + } + + phaseState.status = 'active'; + phaseState.startedAt ??= new Date().toISOString(); + phaseState.error = null; + currentJob = await this.options.onPhaseStarted(phaseName, phases); + + const handler = this.options.handlers.get(phaseName); + if (!handler) { + throw new Error(`No handler registered for phase "${phaseName}"`); + } + + const context: PhaseContext = { + job: currentJob, + phase: phaseName, + signal, + progress: async (percent, message, details) => { + this.ensureNotCancelled(signal); + phaseState.progress = normalizeProgress(percent); + phaseState.message = message ?? null; + + const overallProgress = calculateOverallProgress( + phaseIndex, + phases.length, + phaseState.progress, + ); + + await this.options.onProgress( + phaseName, + phases, + phaseState.progress, + overallProgress, + message, + details, + ); + + return currentJob; + }, + phaseResult: (lookupPhase: string) => + phaseResults[lookupPhase] as TResult | undefined, + phaseResults: () => ({ ...phaseResults }), + isCancelled: () => signal.aborted, + throwIfCancelled: async () => { + this.ensureNotCancelled(signal); + }, + }; + + const result = await handler(currentJob, context); + this.ensureNotCancelled(signal); + + phaseState.status = 'completed'; + phaseState.progress = 100; + phaseState.message = null; + phaseState.completedAt = new Date().toISOString(); + phaseResults[phaseName] = result; + + currentJob = await this.options.onPhaseCompleted( + phaseName, + phases, + phaseResults, + calculateOverallProgress(phaseIndex, phases.length, 100), + ); + } + + return { phases, phaseResults }; + } + + private ensureNotCancelled(signal: AbortSignal): void { + if (signal.aborted) { + throw signal.reason instanceof Error ? signal.reason : new Error('Job cancelled'); + } + } +} + +function calculateOverallProgress( + phaseIndex: number, + totalPhases: number, + phaseProgress: number, +): number { + const clamped = normalizeProgress(phaseProgress); + return Math.round(((phaseIndex + clamped / 100) / totalPhases) * 100); +} + +function normalizeProgress(value: number): number { + return Math.min(100, Math.max(0, Math.round(value))); +} diff --git a/src/processor/WorkerPool.ts b/src/processor/WorkerPool.ts new file mode 100644 index 0000000..189c798 --- /dev/null +++ b/src/processor/WorkerPool.ts @@ -0,0 +1,58 @@ +import pLimit from 'p-limit'; + +export class WorkerPool { + private readonly limit: ReturnType; + private readonly running = new Set>(); + + public constructor(private readonly concurrency: number) { + if (!Number.isInteger(concurrency) || concurrency <= 0) { + throw new Error('concurrency must be a positive integer'); + } + + this.limit = pLimit(concurrency); + } + + public get activeCount(): number { + return this.limit.activeCount; + } + + public get pendingCount(): number { + return this.limit.pendingCount; + } + + public get availableSlots(): number { + return Math.max(this.concurrency - this.activeCount - this.pendingCount, 0); + } + + public hasCapacity(): boolean { + return this.availableSlots > 0; + } + + public run(task: () => Promise): Promise { + const scheduled = this.limit(async () => task()); + const tracked = scheduled.finally(() => { + this.running.delete(tracked); + }); + + this.running.add(tracked); + return scheduled; + } + + public async drain(timeoutMs?: number): Promise { + const active = Promise.allSettled([...this.running]); + + if (timeoutMs === undefined) { + await active; + return; + } + + await Promise.race([ + active.then(() => undefined), + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Timed out waiting for workers to drain after ${timeoutMs}ms`)); + }, timeoutMs); + }), + ]); + } +} diff --git a/src/retention/RetentionScheduler.ts b/src/retention/RetentionScheduler.ts new file mode 100644 index 0000000..86a0ee9 --- /dev/null +++ b/src/retention/RetentionScheduler.ts @@ -0,0 +1,43 @@ +import type { JobData, JobRecord, RetentionConfig } from '../types.js'; + +export class RetentionScheduler { + private interval: NodeJS.Timeout | null = null; + + public constructor( + private readonly config: RetentionConfig, + private readonly handlers: { + markStale: (cutoffIso: string) => Promise[]>; + deleteStale: (cutoffIso: string) => Promise[]>; + }, + ) {} + + public start(): void { + if (this.interval) { + return; + } + + const intervalMs = this.config.intervalMs ?? 60_000; + this.interval = setInterval(() => { + void this.runCycle(); + }, intervalMs); + this.interval.unref?.(); + } + + public async runCycle(): Promise { + const now = Date.now(); + const staleCutoff = new Date(now - this.config.staleAfterMs).toISOString(); + const deleteCutoff = new Date(now - this.config.deleteAfterMs).toISOString(); + + await this.handlers.markStale(staleCutoff); + await this.handlers.deleteStale(deleteCutoff); + } + + public stop(): void { + if (!this.interval) { + return; + } + + clearInterval(this.interval); + this.interval = null; + } +} diff --git a/src/retry/RetryStrategy.ts b/src/retry/RetryStrategy.ts new file mode 100644 index 0000000..e2dfe16 --- /dev/null +++ b/src/retry/RetryStrategy.ts @@ -0,0 +1,82 @@ +import type { + ErrorDisposition, + JobData, + JobRecord, + RetryBackoffStrategy, + RetryConfig, +} from '../types.js'; + +const DEFAULT_MAX_ATTEMPTS = 1; +const DEFAULT_BASE_DELAY_MS = 1_000; +const DEFAULT_MAX_DELAY_MS = 30_000; + +export class RetryStrategy { + private readonly config: Required< + Pick, 'maxAttempts' | 'strategy' | 'baseDelayMs' | 'maxDelayMs'> + > & + Pick, 'classifyError'>; + + public constructor(config: RetryConfig = {}) { + this.config = { + maxAttempts: config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + strategy: config.strategy ?? 'exponential', + baseDelayMs: config.baseDelayMs ?? DEFAULT_BASE_DELAY_MS, + maxDelayMs: config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS, + classifyError: config.classifyError, + }; + } + + public defaultMaxAttempts(): number { + return this.config.maxAttempts; + } + + public async classify(error: unknown, job: JobRecord): Promise { + if (this.config.classifyError) { + return this.config.classifyError(error, job); + } + + return 'fatal'; + } + + public getDelayMs(retryCount: number): number { + const multiplier = this.getMultiplier(retryCount); + return Math.min(this.config.baseDelayMs * multiplier, this.config.maxDelayMs); + } + + public async shouldRetry( + error: unknown, + job: JobRecord, + ): Promise<{ retry: boolean; delayMs: number; disposition: ErrorDisposition }> { + const disposition = await this.classify(error, job); + const nextRetryCount = job.retryCount + 1; + + if (disposition !== 'recoverable' || nextRetryCount >= job.maxAttempts) { + return { + retry: false, + delayMs: 0, + disposition, + }; + } + + return { + retry: true, + delayMs: this.getDelayMs(nextRetryCount), + disposition, + }; + } + + private getMultiplier(retryCount: number): number { + const normalizedRetryCount = Math.max(retryCount, 1); + const strategy: RetryBackoffStrategy = this.config.strategy; + + if (strategy === 'fixed') { + return 1; + } + + if (strategy === 'linear') { + return normalizedRetryCount; + } + + return 2 ** (normalizedRetryCount - 1); + } +} diff --git a/src/storage/SqliteStorage.ts b/src/storage/SqliteStorage.ts new file mode 100644 index 0000000..9756be1 --- /dev/null +++ b/src/storage/SqliteStorage.ts @@ -0,0 +1,580 @@ +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import Database from 'better-sqlite3'; + +import type { + EnqueueOptions, + JobData, + JobFailure, + JobPhaseState, + JobRecord, + JobStatus, + ListJobsOptions, +} from '../types.js'; + +interface JobRow { + id: string; + status: JobStatus; + data: string; + current_phase: string | null; + phases_json: string; + phase_results: string; + progress: number; + progress_message: string | null; + error_json: string | null; + retry_count: number; + max_attempts: number; + webhook_url: string | null; + webhook_sent: number; + created_at: string; + started_at: string | null; + completed_at: string | null; + updated_at: string; + scheduled_at: string | null; + cancelled_at: string | null; +} + +function toIsoString(value: Date | string | undefined): string | null { + if (!value) { + return null; + } + + return value instanceof Date ? value.toISOString() : value; +} + +function parseJson(value: string | null, fallback: T): T { + if (!value) { + return fallback; + } + + return JSON.parse(value) as T; +} + +export class SqliteStorage { + private readonly db: Database.Database; + + public constructor(dbPath: string) { + mkdirSync(dirname(dbPath), { recursive: true }); + this.db = new Database(dbPath); + this.db.pragma('journal_mode = WAL'); + this.db.pragma('foreign_keys = ON'); + this.db.pragma('busy_timeout = 5000'); + this.init(); + } + + public createJob( + id: string, + data: TData, + phases: JobPhaseState[], + options: EnqueueOptions, + maxAttempts: number, + ): JobRecord { + const now = new Date().toISOString(); + const row: JobRow = { + id, + status: 'pending', + data: JSON.stringify(data), + current_phase: null, + phases_json: JSON.stringify(phases), + phase_results: JSON.stringify({}), + progress: 0, + progress_message: null, + error_json: null, + retry_count: 0, + max_attempts: maxAttempts, + webhook_url: options.webhookUrl ?? null, + webhook_sent: 0, + created_at: now, + started_at: null, + completed_at: null, + updated_at: now, + scheduled_at: toIsoString(options.scheduledAt), + cancelled_at: null, + }; + + this.db + .prepare( + ` + INSERT INTO jobs ( + id, status, data, current_phase, phases_json, phase_results, progress, + progress_message, error_json, retry_count, max_attempts, webhook_url, + webhook_sent, created_at, started_at, completed_at, updated_at, + scheduled_at, cancelled_at + ) + VALUES ( + @id, @status, @data, @current_phase, @phases_json, @phase_results, @progress, + @progress_message, @error_json, @retry_count, @max_attempts, @webhook_url, + @webhook_sent, @created_at, @started_at, @completed_at, @updated_at, + @scheduled_at, @cancelled_at + ) + `, + ) + .run(row); + + return this.parseRow(row); + } + + public getJob(id: string): JobRecord | null { + const row = this.db.prepare('SELECT * FROM jobs WHERE id = ?').get(id) as JobRow | undefined; + return row ? this.parseRow(row) : null; + } + + public listJobs(options: ListJobsOptions = {}): JobRecord[] { + const where: string[] = []; + const params: Array = []; + + if (options.statuses && options.statuses.length > 0) { + where.push(`status IN (${options.statuses.map(() => '?').join(', ')})`); + params.push(...options.statuses); + } + + const limit = options.limit ?? 100; + const offset = options.offset ?? 0; + params.push(limit, offset); + + const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + const rows = this.db + .prepare( + ` + SELECT * FROM jobs + ${whereClause} + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `, + ) + .all(...params) as JobRow[]; + + return rows.map((row) => this.parseRow(row)); + } + + public listRunnableJobs(limit: number): JobRecord[] { + const now = new Date().toISOString(); + const rows = this.db + .prepare( + ` + SELECT * FROM jobs + WHERE status = 'pending' + AND (scheduled_at IS NULL OR scheduled_at <= ?) + ORDER BY COALESCE(scheduled_at, created_at) ASC, created_at ASC + LIMIT ? + `, + ) + .all(now, limit) as JobRow[]; + + return rows.map((row) => this.parseRow(row)); + } + + public claimPendingJob(id: string): boolean { + const now = new Date().toISOString(); + const result = this.db + .prepare( + ` + UPDATE jobs + SET status = 'active', + started_at = COALESCE(started_at, ?), + updated_at = ?, + cancelled_at = NULL + WHERE id = ? + AND status = 'pending' + AND (scheduled_at IS NULL OR scheduled_at <= ?) + `, + ) + .run(now, now, id, now); + + return result.changes > 0; + } + + public saveProgress( + id: string, + currentPhase: string, + phases: JobPhaseState[], + progress: number, + message: string | null, + ): JobRecord { + const now = new Date().toISOString(); + this.db + .prepare( + ` + UPDATE jobs + SET current_phase = ?, + phases_json = ?, + progress = ?, + progress_message = ?, + updated_at = ? + WHERE id = ? + `, + ) + .run(currentPhase, JSON.stringify(phases), progress, message, now, id); + + return this.mustGetJob(id); + } + + public savePhaseCompletion( + id: string, + phases: JobPhaseState[], + phaseResults: Record, + currentPhase: string | null, + progress: number, + message: string | null, + ): JobRecord { + const now = new Date().toISOString(); + this.db + .prepare( + ` + UPDATE jobs + SET phases_json = ?, + phase_results = ?, + current_phase = ?, + progress = ?, + progress_message = ?, + error_json = NULL, + updated_at = ? + WHERE id = ? + `, + ) + .run( + JSON.stringify(phases), + JSON.stringify(phaseResults), + currentPhase, + progress, + message, + now, + id, + ); + + return this.mustGetJob(id); + } + + public completeJob( + id: string, + phases: JobPhaseState[], + phaseResults: Record, + ): JobRecord { + const now = new Date().toISOString(); + this.db + .prepare( + ` + UPDATE jobs + SET status = 'completed', + phases_json = ?, + phase_results = ?, + current_phase = NULL, + progress = 100, + progress_message = NULL, + error_json = NULL, + completed_at = ?, + updated_at = ? + WHERE id = ? + `, + ) + .run(JSON.stringify(phases), JSON.stringify(phaseResults), now, now, id); + + return this.mustGetJob(id); + } + + public failJob(id: string, phases: JobPhaseState[], failure: JobFailure): JobRecord { + const now = new Date().toISOString(); + this.db + .prepare( + ` + UPDATE jobs + SET status = 'failed', + phases_json = ?, + current_phase = ?, + error_json = ?, + completed_at = ?, + updated_at = ? + WHERE id = ? + `, + ) + .run(JSON.stringify(phases), failure.phase, JSON.stringify(failure), now, now, id); + + return this.mustGetJob(id); + } + + public scheduleRetry( + id: string, + phases: JobPhaseState[], + progress: number, + failure: JobFailure, + retryCount: number, + scheduledAt: string, + ): JobRecord { + const now = new Date().toISOString(); + this.db + .prepare( + ` + UPDATE jobs + SET status = 'pending', + phases_json = ?, + current_phase = ?, + progress = ?, + progress_message = ?, + error_json = ?, + retry_count = ?, + scheduled_at = ?, + updated_at = ? + WHERE id = ? + `, + ) + .run( + JSON.stringify(phases), + failure.phase, + progress, + `Retry scheduled in ${Math.max(new Date(scheduledAt).getTime() - Date.now(), 0)}ms`, + JSON.stringify(failure), + retryCount, + scheduledAt, + now, + id, + ); + + return this.mustGetJob(id); + } + + public resetForRetry( + id: string, + phases: JobPhaseState[], + maxAttempts: number, + scheduledAt: string | null, + ): JobRecord { + const now = new Date().toISOString(); + this.db + .prepare( + ` + UPDATE jobs + SET status = 'pending', + current_phase = NULL, + phases_json = ?, + phase_results = '{}', + progress = 0, + progress_message = NULL, + error_json = NULL, + retry_count = 0, + max_attempts = ?, + webhook_sent = 0, + completed_at = NULL, + updated_at = ?, + scheduled_at = ?, + cancelled_at = NULL + WHERE id = ? + `, + ) + .run(JSON.stringify(phases), maxAttempts, now, scheduledAt, id); + + return this.mustGetJob(id); + } + + public cancelJob(id: string, phases: JobPhaseState[]): JobRecord { + const now = new Date().toISOString(); + this.db + .prepare( + ` + UPDATE jobs + SET status = 'cancelled', + phases_json = ?, + current_phase = NULL, + completed_at = COALESCE(completed_at, ?), + cancelled_at = ?, + updated_at = ? + WHERE id = ? + AND status IN ('pending', 'active') + `, + ) + .run(JSON.stringify(phases), now, now, now, id); + + return this.mustGetJob(id); + } + + public markWebhookSent(id: string): void { + this.db + .prepare('UPDATE jobs SET webhook_sent = 1, updated_at = ? WHERE id = ?') + .run(new Date().toISOString(), id); + } + + public resetActiveJobs(message: string): JobRecord[] { + const activeRows = this.db + .prepare(`SELECT * FROM jobs WHERE status = 'active'`) + .all() as JobRow[]; + + if (activeRows.length === 0) { + return []; + } + + const now = new Date().toISOString(); + const failure: JobFailure = { + message, + phase: null, + recoverable: false, + timestamp: now, + attempt: 0, + }; + + const statement = this.db.prepare( + ` + UPDATE jobs + SET status = 'failed', + error_json = ?, + completed_at = ?, + updated_at = ? + WHERE id = ? + `, + ); + + const transaction = this.db.transaction((rows: JobRow[]) => { + for (const row of rows) { + statement.run(JSON.stringify(failure), now, now, row.id); + } + }); + + transaction(activeRows); + return activeRows.map((row) => this.mustGetJob(row.id)); + } + + public markStaleJobs(cutoffIso: string): JobRecord[] { + const rows = this.db + .prepare( + ` + SELECT * FROM jobs + WHERE status IN ('completed', 'failed', 'cancelled') + AND COALESCE(completed_at, updated_at) <= ? + `, + ) + .all(cutoffIso) as JobRow[]; + + if (rows.length === 0) { + return []; + } + + const now = new Date().toISOString(); + const update = this.db.prepare( + ` + UPDATE jobs + SET status = 'stale', + updated_at = ? + WHERE id = ? + `, + ); + + const transaction = this.db.transaction((staleRows: JobRow[]) => { + for (const row of staleRows) { + update.run(now, row.id); + } + }); + + transaction(rows); + return rows.map((row) => this.mustGetJob(row.id)); + } + + public deleteStaleJobs(cutoffIso: string): JobRecord[] { + const rows = this.db + .prepare( + ` + SELECT * FROM jobs + WHERE status = 'stale' + AND COALESCE(completed_at, updated_at) <= ? + `, + ) + .all(cutoffIso) as JobRow[]; + + if (rows.length === 0) { + return []; + } + + const remove = this.db.prepare('DELETE FROM jobs WHERE id = ?'); + const transaction = this.db.transaction((deleteRows: JobRow[]) => { + for (const row of deleteRows) { + remove.run(row.id); + } + }); + + transaction(rows); + return rows.map((row) => this.parseRow(row)); + } + + public getNextScheduledAt(): string | null { + const row = this.db + .prepare( + ` + SELECT MIN(scheduled_at) AS scheduled_at + FROM jobs + WHERE status = 'pending' + AND scheduled_at IS NOT NULL + `, + ) + .get() as { scheduled_at: string | null }; + + return row.scheduled_at; + } + + public close(): void { + this.db.close(); + } + + private init(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + data TEXT NOT NULL, + current_phase TEXT, + phases_json TEXT NOT NULL, + phase_results TEXT NOT NULL DEFAULT '{}', + progress INTEGER NOT NULL DEFAULT 0, + progress_message TEXT, + error_json TEXT, + retry_count INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 1, + webhook_url TEXT, + webhook_sent INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL, + scheduled_at TEXT, + cancelled_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); + CREATE INDEX IF NOT EXISTS idx_jobs_scheduled_at ON jobs(scheduled_at); + CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at); + `); + } + + private mustGetJob(id: string): JobRecord { + const job = this.getJob(id); + + if (!job) { + throw new Error(`Job ${id} not found`); + } + + return job; + } + + private parseRow(row: JobRow): JobRecord { + return { + id: row.id, + status: row.status, + data: parseJson(row.data, {} as TData), + currentPhase: row.current_phase, + phases: parseJson(row.phases_json, []), + phaseResults: parseJson>(row.phase_results, {}) as Record< + string, + TData[keyof TData] | undefined + >, + progress: row.progress, + progressMessage: row.progress_message, + error: parseJson(row.error_json, null), + retryCount: row.retry_count, + maxAttempts: row.max_attempts, + webhookUrl: row.webhook_url, + webhookSent: Boolean(row.webhook_sent), + createdAt: row.created_at, + startedAt: row.started_at, + completedAt: row.completed_at, + updatedAt: row.updated_at, + scheduledAt: row.scheduled_at, + cancelledAt: row.cancelled_at, + }; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4546a78 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,216 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +export type Awaitable = T | Promise; +export type JobData = Record; +export type JobStatus = + | 'pending' + | 'active' + | 'completed' + | 'failed' + | 'cancelled' + | 'stale'; +export type JobPhaseStatus = 'pending' | 'active' | 'completed' | 'failed' | 'cancelled'; +export type RetryBackoffStrategy = 'fixed' | 'linear' | 'exponential'; +export type ErrorDisposition = 'recoverable' | 'fatal'; + +export interface JobPhaseState { + name: string; + status: JobPhaseStatus; + progress: number; + message: string | null; + startedAt: string | null; + completedAt: string | null; + error: string | null; +} + +export interface JobFailure { + message: string; + phase: string | null; + recoverable: boolean; + timestamp: string; + attempt: number; +} + +export interface JobRetryEvent { + jobId: string; + phase: string | null; + attempt: number; + delayMs: number; + nextRunAt: string; + timestamp: string; +} + +export interface JobProgressEvent { + jobId: string; + phase: string; + phaseProgress: number; + overallProgress: number; + message: string | null; + timestamp: string; + details?: JsonValue; +} + +export interface JobRecord { + id: string; + status: JobStatus; + data: TData; + currentPhase: string | null; + phases: JobPhaseState[]; + phaseResults: Record; + progress: number; + progressMessage: string | null; + error: JobFailure | null; + retryCount: number; + maxAttempts: number; + webhookUrl: string | null; + webhookSent: boolean; + createdAt: string; + startedAt: string | null; + completedAt: string | null; + updatedAt: string; + scheduledAt: string | null; + cancelledAt: string | null; +} + +export interface EnqueueOptions { + id?: string; + scheduledAt?: string | Date; + maxAttempts?: number; + webhookUrl?: string; +} + +export interface RetryOptions { + fromStart?: boolean; + scheduledAt?: string | Date; +} + +export interface ListJobsOptions { + statuses?: JobStatus[]; + limit?: number; + offset?: number; +} + +export interface StreamOptions { + jobId?: string; + includeSnapshot?: boolean; + keepAliveMs?: number; +} + +export interface RetryConfig { + maxAttempts?: number; + strategy?: RetryBackoffStrategy; + baseDelayMs?: number; + maxDelayMs?: number; + classifyError?: ( + error: unknown, + job: JobRecord, + ) => Awaitable; +} + +export interface RetentionConfig { + staleAfterMs: number; + deleteAfterMs: number; + intervalMs?: number; + onStale?: (job: JobRecord) => Awaitable; + onDelete?: (job: JobRecord) => Awaitable; +} + +export type WebhookEventName = + | 'job:completed' + | 'job:failed' + | 'job:retrying' + | 'job:cancelled' + | 'job:stale'; + +export interface WebhookConfig { + url?: string; + events?: WebhookEventName[]; + secret?: string; + timeoutMs?: number; + headers?: Record; + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; +} + +export interface QueueConfig { + dbPath: string; + phases: readonly string[]; + concurrency?: number; + retry?: RetryConfig; + retention?: RetentionConfig; + webhook?: WebhookConfig; + shutdownTimeoutMs?: number; +} + +export interface PhaseContext { + readonly job: JobRecord; + readonly phase: string; + readonly signal: AbortSignal; + progress: (percent: number, message?: string, details?: JsonValue) => Promise>; + phaseResult: (phaseName: string) => TResult | undefined; + phaseResults: () => Record; + isCancelled: () => boolean; + throwIfCancelled: () => Promise; +} + +export type PhaseHandler = ( + job: JobRecord, + context: PhaseContext, +) => Awaitable; + +export interface WebhookDispatchResult { + event: WebhookEventName; + jobId: string; + status: number; + deliveredAt: string; +} + +export interface WebhookDispatchError { + event: WebhookEventName; + jobId: string; + message: string; + finalAttempt: number; +} + +export interface QueueStreamEvent { + type: + | 'snapshot' + | 'job:enqueued' + | 'job:started' + | 'job:progress' + | 'job:phase:completed' + | 'job:completed' + | 'job:failed' + | 'job:retrying' + | 'job:cancelled' + | 'job:stale' + | 'job:deleted' + | 'job:webhook:delivered' + | 'job:webhook:failed' + | 'ping'; + jobId?: string; + job?: JobRecord; + progress?: JobProgressEvent; + phase?: JobPhaseState; + failure?: JobFailure; + retry?: JobRetryEvent; + webhook?: WebhookDispatchResult | WebhookDispatchError; + deletedJobId?: string; + timestamp: string; +} + +export interface JobQueueEvents { + 'job:enqueued': [job: JobRecord]; + 'job:started': [job: JobRecord]; + 'job:progress': [job: JobRecord, progress: JobProgressEvent]; + 'job:phase:completed': [job: JobRecord, phase: JobPhaseState]; + 'job:completed': [job: JobRecord]; + 'job:failed': [job: JobRecord, failure: JobFailure]; + 'job:retrying': [job: JobRecord, retry: JobRetryEvent]; + 'job:cancelled': [job: JobRecord]; + 'job:stale': [job: JobRecord]; + 'job:deleted': [jobId: string]; + 'job:webhook:delivered': [job: JobRecord, result: WebhookDispatchResult]; + 'job:webhook:failed': [job: JobRecord, error: WebhookDispatchError]; +} diff --git a/src/util/errors.ts b/src/util/errors.ts new file mode 100644 index 0000000..3ad4256 --- /dev/null +++ b/src/util/errors.ts @@ -0,0 +1,6 @@ +export class CancellationError extends Error { + public constructor(message = 'Job cancelled') { + super(message); + this.name = 'CancellationError'; + } +} diff --git a/src/util/id.ts b/src/util/id.ts new file mode 100644 index 0000000..028a75b --- /dev/null +++ b/src/util/id.ts @@ -0,0 +1,5 @@ +import { randomUUID } from 'node:crypto'; + +export function createJobId(): string { + return randomUUID(); +} diff --git a/src/webhook/WebhookDispatcher.ts b/src/webhook/WebhookDispatcher.ts new file mode 100644 index 0000000..308acaa --- /dev/null +++ b/src/webhook/WebhookDispatcher.ts @@ -0,0 +1,143 @@ +import { createHmac } from 'node:crypto'; + +import type { + JobData, + JobRecord, + WebhookConfig, + WebhookDispatchError, + WebhookDispatchResult, + WebhookEventName, +} from '../types.js'; + +const DEFAULT_TIMEOUT_MS = 5_000; +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_BASE_DELAY_MS = 500; +const DEFAULT_MAX_DELAY_MS = 5_000; + +function sleep(delayMs: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); +} + +export class WebhookDispatcher { + private readonly config: Required< + Pick + > & + Pick; + + public constructor(config: WebhookConfig = {}) { + this.config = { + url: config.url, + events: config.events ?? ['job:completed', 'job:failed'], + timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS, + headers: config.headers, + secret: config.secret, + maxAttempts: config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + baseDelayMs: config.baseDelayMs ?? DEFAULT_BASE_DELAY_MS, + maxDelayMs: config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS, + }; + } + + public supports(event: WebhookEventName, job: JobRecord): boolean { + return Boolean(job.webhookUrl ?? this.config.url) && this.config.events.includes(event); + } + + public async dispatch( + event: WebhookEventName, + job: JobRecord, + ): Promise { + const url = job.webhookUrl ?? this.config.url; + + if (!url) { + throw new Error(`No webhook URL configured for ${event}`); + } + + const payload = JSON.stringify({ + event, + emittedAt: new Date().toISOString(), + job, + }); + const signature = this.sign(payload); + let attempt = 0; + let lastError: Error | null = null; + + while (attempt < this.config.maxAttempts) { + attempt += 1; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, this.config.timeoutMs); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-JobQueue-Event': event, + ...(signature ? { 'X-JobQueue-Signature': signature } : {}), + ...(this.config.headers ?? {}), + }, + body: payload, + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (response.ok) { + return { + event, + jobId: job.id, + status: response.status, + deliveredAt: new Date().toISOString(), + }; + } + + if (response.status < 500) { + throw Object.assign(new Error(`Webhook returned non-retryable status ${response.status}`), { + retryable: false, + }); + } + + throw new Error(`Webhook returned retryable status ${response.status}`); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + const retryable = + typeof error === 'object' && + error !== null && + 'retryable' in error && + (error as { retryable?: boolean }).retryable === false + ? false + : true; + + if (!retryable || attempt >= this.config.maxAttempts) { + break; + } + + await sleep(this.nextDelay(attempt)); + } + } + + const dispatchError: WebhookDispatchError = { + event, + jobId: job.id, + message: lastError?.message ?? 'Unknown webhook delivery error', + finalAttempt: attempt, + }; + + throw Object.assign(new Error(dispatchError.message), { dispatchError }); + } + + private sign(payload: string): string | undefined { + if (!this.config.secret) { + return undefined; + } + + return createHmac('sha256', this.config.secret).update(payload).digest('hex'); + } + + private nextDelay(attempt: number): number { + return Math.min(this.config.baseDelayMs * 2 ** (attempt - 1), this.config.maxDelayMs); + } +} diff --git a/tests/JobQueue.test.ts b/tests/JobQueue.test.ts new file mode 100644 index 0000000..c3a7984 --- /dev/null +++ b/tests/JobQueue.test.ts @@ -0,0 +1,135 @@ +import { JobQueue } from '../src/index.js'; +import { cleanupDir, createDbPath, createTempDir, waitFor } from './helpers.js'; + +describe('JobQueue', () => { + it('runs multi-phase jobs to completion', async () => { + const dir = createTempDir(); + const queue = new JobQueue<{ url: string }>({ + dbPath: createDbPath(dir), + phases: ['download', 'process'], + concurrency: 1, + }); + const events: string[] = []; + + queue.on('job:started', () => events.push('started')); + queue.on('job:phase:completed', (_, phase) => events.push(`phase:${phase.name}`)); + queue.on('job:completed', () => events.push('completed')); + + queue.handle('download', async (_job, ctx) => { + await ctx.progress(50, 'downloading'); + return { filePath: '/tmp/video.mp4' }; + }); + + queue.handle('process', async (_job, ctx) => { + expect(ctx.phaseResult<{ filePath: string }>('download')?.filePath).toBe('/tmp/video.mp4'); + await ctx.progress(25, 'processing'); + return { outputPath: '/tmp/video.txt' }; + }); + + try { + const jobId = await queue.enqueue({ url: 'https://example.com/video' }); + await waitFor(() => queue.getJob(jobId)?.status === 'completed'); + + const job = queue.getJob(jobId); + expect(job?.status).toBe('completed'); + expect(job?.phaseResults.download).toEqual({ filePath: '/tmp/video.mp4' }); + expect(job?.phaseResults.process).toEqual({ outputPath: '/tmp/video.txt' }); + expect(events).toEqual(['started', 'phase:download', 'phase:process', 'completed']); + } finally { + await queue.shutdown(); + cleanupDir(dir); + } + }); + + it('retries recoverable failures and eventually completes', async () => { + const dir = createTempDir(); + const queue = new JobQueue<{ url: string }>({ + dbPath: createDbPath(dir), + phases: ['run'], + concurrency: 1, + retry: { + maxAttempts: 3, + baseDelayMs: 10, + classifyError: async (error) => + error instanceof Error && error.message === 'recoverable' ? 'recoverable' : 'fatal', + }, + }); + let attempts = 0; + let retries = 0; + + queue.on('job:retrying', () => { + retries += 1; + }); + + queue.handle('run', async () => { + attempts += 1; + if (attempts === 1) { + throw new Error('recoverable'); + } + + return { ok: true }; + }); + + try { + const jobId = await queue.enqueue({ url: 'https://example.com/video' }); + await waitFor(() => queue.getJob(jobId)?.status === 'completed', { timeoutMs: 4_000 }); + + const job = queue.getJob(jobId); + expect(job?.status).toBe('completed'); + expect(job?.retryCount).toBe(1); + expect(retries).toBe(1); + } finally { + await queue.shutdown(); + cleanupDir(dir); + } + }); + + it('streams queue events as SSE', async () => { + const dir = createTempDir(); + const queue = new JobQueue<{ url: string }>({ + dbPath: createDbPath(dir), + phases: ['run'], + concurrency: 1, + }); + const stream = queue.createEventStream({ includeSnapshot: false }); + const reader = stream.getReader(); + const chunks: string[] = []; + const readPromise = (async () => { + while (true) { + const { value, done } = await reader.read(); + if (done) { + return; + } + + if (value) { + chunks.push(new TextDecoder().decode(value)); + if (chunks.some((chunk) => chunk.includes('event: job:completed'))) { + return; + } + } + } + })(); + + queue.handle('run', async (_job, ctx) => { + await ctx.progress(100, 'done'); + return { ok: true }; + }); + + try { + await queue.enqueue({ url: 'https://example.com/video' }); + await Promise.race([ + readPromise, + waitFor(() => chunks.some((chunk) => chunk.includes('event: job:completed')), { + timeoutMs: 4_000, + }), + ]); + } finally { + await reader.cancel(); + await queue.shutdown(); + cleanupDir(dir); + } + + expect(chunks.join('\n')).toContain('event: job:completed'); + expect(chunks.join('\n')).toContain('event: job:progress'); + }); +}); diff --git a/tests/RetentionScheduler.test.ts b/tests/RetentionScheduler.test.ts new file mode 100644 index 0000000..4a3b79b --- /dev/null +++ b/tests/RetentionScheduler.test.ts @@ -0,0 +1,28 @@ +import { RetentionScheduler } from '../src/index.js'; + +describe('RetentionScheduler', () => { + it('runs stale and delete handlers', async () => { + const calls: string[] = []; + const scheduler = new RetentionScheduler( + { + staleAfterMs: 1_000, + deleteAfterMs: 2_000, + }, + { + markStale: async () => { + calls.push('stale'); + return []; + }, + deleteStale: async () => { + calls.push('delete'); + return []; + }, + }, + ); + + await scheduler.runCycle(); + scheduler.stop(); + + expect(calls).toEqual(['stale', 'delete']); + }); +}); diff --git a/tests/RetryStrategy.test.ts b/tests/RetryStrategy.test.ts new file mode 100644 index 0000000..8b3d04e --- /dev/null +++ b/tests/RetryStrategy.test.ts @@ -0,0 +1,72 @@ +import { RetryStrategy } from '../src/index.js'; + +describe('RetryStrategy', () => { + it('uses exponential backoff for recoverable errors', async () => { + const strategy = new RetryStrategy({ + maxAttempts: 4, + baseDelayMs: 100, + classifyError: async () => 'recoverable', + }); + + const decision = await strategy.shouldRetry(new Error('boom'), { + id: 'job-1', + status: 'active', + data: {}, + currentPhase: 'run', + phases: [], + phaseResults: {}, + progress: 0, + progressMessage: null, + error: null, + retryCount: 1, + maxAttempts: 4, + webhookUrl: null, + webhookSent: false, + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + updatedAt: new Date().toISOString(), + scheduledAt: null, + cancelledAt: null, + }); + + expect(decision.retry).toBe(true); + expect(decision.delayMs).toBe(200); + expect(decision.disposition).toBe('recoverable'); + }); + + it('does not retry fatal errors', async () => { + const strategy = new RetryStrategy({ + maxAttempts: 4, + classifyError: async () => 'fatal', + }); + + const decision = await strategy.shouldRetry(new Error('fatal'), { + id: 'job-1', + status: 'active', + data: {}, + currentPhase: 'run', + phases: [], + phaseResults: {}, + progress: 0, + progressMessage: null, + error: null, + retryCount: 0, + maxAttempts: 4, + webhookUrl: null, + webhookSent: false, + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + updatedAt: new Date().toISOString(), + scheduledAt: null, + cancelledAt: null, + }); + + expect(decision).toEqual({ + retry: false, + delayMs: 0, + disposition: 'fatal', + }); + }); +}); diff --git a/tests/SqliteStorage.test.ts b/tests/SqliteStorage.test.ts new file mode 100644 index 0000000..e2a3a06 --- /dev/null +++ b/tests/SqliteStorage.test.ts @@ -0,0 +1,76 @@ +import { SqliteStorage } from '../src/index.js'; +import { cleanupDir, createDbPath, createTempDir } from './helpers.js'; + +describe('SqliteStorage', () => { + it('creates, updates, and completes jobs', () => { + const dir = createTempDir(); + const storage = new SqliteStorage<{ url: string }>(createDbPath(dir)); + + try { + const job = storage.createJob( + 'job-1', + { url: 'https://example.com' }, + [ + { + name: 'download', + status: 'pending', + progress: 0, + message: null, + startedAt: null, + completedAt: null, + error: null, + }, + ], + {}, + 3, + ); + + expect(job.status).toBe('pending'); + expect(storage.claimPendingJob(job.id)).toBe(true); + + const inProgress = storage.saveProgress( + job.id, + 'download', + [ + { + name: 'download', + status: 'active', + progress: 50, + message: 'halfway', + startedAt: new Date().toISOString(), + completedAt: null, + error: null, + }, + ], + 50, + 'halfway', + ); + + expect(inProgress.status).toBe('active'); + expect(inProgress.progress).toBe(50); + + const completed = storage.completeJob( + job.id, + [ + { + name: 'download', + status: 'completed', + progress: 100, + message: null, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + error: null, + }, + ], + { download: { filePath: '/tmp/file' } }, + ); + + expect(completed.status).toBe('completed'); + expect(completed.progress).toBe(100); + expect(completed.phaseResults.download).toEqual({ filePath: '/tmp/file' }); + } finally { + storage.close(); + cleanupDir(dir); + } + }); +}); diff --git a/tests/SseSerializer.test.ts b/tests/SseSerializer.test.ts new file mode 100644 index 0000000..57d61e7 --- /dev/null +++ b/tests/SseSerializer.test.ts @@ -0,0 +1,25 @@ +import { SseSerializer } from '../src/index.js'; + +describe('SseSerializer', () => { + it('formats SSE events', () => { + const serializer = new SseSerializer(); + const event = serializer.event('job:completed', { + type: 'job:completed', + jobId: 'job-1', + timestamp: '2026-01-01T00:00:00.000Z', + }); + + expect(event).toContain('event: job:completed'); + expect(event).toContain('"jobId":"job-1"'); + expect(event.endsWith('\n\n')).toBe(true); + }); + + it('returns SSE headers', () => { + expect(SseSerializer.headers()).toEqual({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + }); +}); diff --git a/tests/WebhookDispatcher.test.ts b/tests/WebhookDispatcher.test.ts new file mode 100644 index 0000000..cfe0dac --- /dev/null +++ b/tests/WebhookDispatcher.test.ts @@ -0,0 +1,75 @@ +import { createServer } from 'node:http'; + +import { WebhookDispatcher } from '../src/index.js'; + +describe('WebhookDispatcher', () => { + it('delivers signed webhook payloads', async () => { + const payloads: string[] = []; + const headers: string[] = []; + const server = createServer((request, response) => { + let body = ''; + request.on('data', (chunk) => { + body += chunk.toString(); + }); + request.on('end', () => { + payloads.push(body); + headers.push(String(request.headers['x-jobqueue-signature'])); + response.writeHead(202).end(); + }); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Server address unavailable'); + } + + const dispatcher = new WebhookDispatcher({ + url: `http://127.0.0.1:${address.port}/hook`, + secret: 'top-secret', + events: ['job:completed'], + }); + + try { + const result = await dispatcher.dispatch('job:completed', { + id: 'job-1', + status: 'completed', + data: { url: 'https://example.com' }, + currentPhase: null, + phases: [], + phaseResults: {}, + progress: 100, + progressMessage: null, + error: null, + retryCount: 0, + maxAttempts: 1, + webhookUrl: null, + webhookSent: false, + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + scheduledAt: null, + cancelledAt: null, + }); + + expect(result.status).toBe(202); + expect(payloads).toHaveLength(1); + expect(payloads[0]).toContain('"event":"job:completed"'); + expect(headers[0]).toMatch(/^[a-f0-9]{64}$/); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }); +}); diff --git a/tests/WorkerPool.test.ts b/tests/WorkerPool.test.ts new file mode 100644 index 0000000..e8d063d --- /dev/null +++ b/tests/WorkerPool.test.ts @@ -0,0 +1,26 @@ +import { WorkerPool } from '../src/index.js'; +import { waitFor } from './helpers.js'; + +describe('WorkerPool', () => { + it('respects concurrency limits', async () => { + const pool = new WorkerPool(2); + let active = 0; + let maxActive = 0; + + const tasks = Array.from({ length: 5 }, (_, index) => + pool.run(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => { + setTimeout(resolve, 20 + index); + }); + active -= 1; + }), + ); + + await Promise.all(tasks); + await waitFor(() => pool.activeCount === 0 && pool.pendingCount === 0); + + expect(maxActive).toBe(2); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..273c0d1 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,34 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export function createTempDir(prefix = 'jobqueue-'): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +export function createDbPath(dir: string): string { + return join(dir, 'jobs.db'); +} + +export function cleanupDir(dir: string): void { + rmSync(dir, { recursive: true, force: true }); +} + +export async function waitFor( + predicate: () => boolean, + options: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 2_000; + const intervalMs = options.intervalMs ?? 10; + const start = Date.now(); + + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitFor timed out after ${timeoutMs}ms`); + } + + await new Promise((resolve) => { + setTimeout(resolve, intervalMs); + }); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bcd1fef --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": false + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..989bf98 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + target: 'node20', + splitting: false, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ef25371 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/index.ts'], + }, + }, +});