diff --git a/CHANGELOG.md b/CHANGELOG.md index 092e4fe3b..bbfb7345c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - 🐛(frontend) reduce PostHog volume from web vitals and opt_in spam #2701 - ✨(backend) expose the attachment max size in the config endpoint #2577 - ✨(frontend) warn before uploading an attachment over the size limit #2577 +- ✨(loadtest) add a websocket load generator for the collaboration server - ✨(backend) measure the calls to yhub and to the converters, the database pool and the celery queue - ✨(backend) add a `LoadTest` configuration and its `loadtest` application, diff --git a/src/loadtest/swarm/Dockerfile b/src/loadtest/swarm/Dockerfile new file mode 100644 index 000000000..79c8ca7a3 --- /dev/null +++ b/src/loadtest/swarm/Dockerfile @@ -0,0 +1,19 @@ +# Built from the repository root, like every other image here: +# docker build -f src/loadtest/swarm/Dockerfile -t docs-swarm . +FROM node:22-trixie AS base +WORKDIR /app +COPY ./src/loadtest/swarm/package.json ./src/loadtest/swarm/yarn.lock ./ + +FROM base AS builder +RUN yarn install --frozen-lockfile +COPY ./src/loadtest/swarm/tsconfig.json ./src/loadtest/swarm/tsconfig.build.json ./ +COPY ./src/loadtest/swarm/src ./src +RUN yarn build + +FROM base AS swarm +RUN yarn install --frozen-lockfile --production +COPY --from=builder /app/dist ./dist +# many sockets: the default limit of open files is what would stop the swarm first +ENV UV_THREADPOOL_SIZE=16 +USER node +ENTRYPOINT ["node", "dist/index.js"] diff --git a/src/loadtest/swarm/README.md b/src/loadtest/swarm/README.md new file mode 100644 index 000000000..1e113706f --- /dev/null +++ b/src/loadtest/swarm/README.md @@ -0,0 +1,121 @@ +# Swarm — websocket load generator for the collaboration server + +Opens many clients on the Docs collaboration server (yhub) with the very stack +the frontend uses — `yjs`, `y-websocket`'s `WebsocketProvider` with the same +options, a `ws` socket carrying the session cookie and the origin a browser +would send — and measures what a user would feel: time to connect, time to the +first sync, and how long an edit takes to reach the other clients of the same +document. It is the collaboration half of `documentation/stress-test-plan.md`; +the HTTP half is k6. + +It is a standalone package (its own `package.json` and `yarn.lock`, not a +workspace of `src/frontend`), like `src/yhub-server`. + +## Who the clients are + +The swarm logs nobody in. It reads the manifest that the backend's +`create_load_test_sessions` command writes (`src/backend/loadtest`, only with +the `LoadTest` configuration): the cookie name, and per logged-in user a +session key and the documents that user may edit or read. **The manifest holds +live sessions and is a secret**: hand it to the swarm and to nothing else, and +revoke the sessions when the campaign is over. + +Every client uses one session of the manifest, in turn. With more clients than +sessions a user is logged in several times, which the report warns about: the +API throttles per user, so ask for at least as many sessions as clients. + +## Running it + +```bash +cd src/loadtest/swarm +yarn install --frozen-lockfile && yarn build +node dist/index.js --manifest manifest.json --url wss://docs.example.com \ + --mode wide --clients 500 --ramp 20 --duration 600 --writers 0.3 +``` + +| Option | Default | What it does | +| ------ | ------- | ------------ | +| `--manifest` | — | The manifest file. Required | +| `--url` | — | `wss://host` of the collaboration server, the ingress in a deployment. Required | +| `--origin` | `https://host` of the url | The `Origin` header. yhub refuses origins outside `COLLABORATION_SERVER_ORIGIN` | +| `--org` | `docs` | The yhub organisation (`YHUB_ORG`) | +| `--clients` | `10` | Virtual clients | +| `--ramp` | `10` | Connections opened per second | +| `--duration` | `60` | Seconds to hold once everybody is asked to connect | +| `--mode` | `wide` | `hot`: everybody on one document. `wide`: each client on a document of its own user. `idle`: `wide` with nobody writing | +| `--doc` | first public document | The document of `hot` mode | +| `--writers` | `0.3` (`0` in `idle`) | Share of the clients that edit | +| `--edit-interval` | `2000` | Milliseconds between two edits of a writer | +| `--edit-size` | `12` | Characters typed per edit | +| `--awareness-interval` | `5000` | Milliseconds between two awareness updates of a writer, `0` for none | +| `--storm-at` | `0` | Second of the hold at which every client drops its socket and reconnects (with `--reconnect-jitter` ms of jitter, `3000` like the frontend) | +| `--settle` | `15` | Seconds to wait after the last edit before checking convergence | +| `--metrics-port` | `9465` | Where `/metrics` is served, `0` for nowhere. `--metrics-token` requires a bearer token on it | +| `--report` | `-` | Where the JSON report goes, `-` for stdout | + +Progress goes to stderr every `--progress-interval` seconds. The exit code is +`0` when every client connected at least once and every document converged. + +Scenarios of the plan, as options: + +| Scenario | Options | +| -------- | ------- | +| connect ramp | `--mode idle --ramp N`, raise N | +| idle steady state | `--mode idle --clients 10000 --duration 1800` | +| hot document | `--mode hot --clients 200 --writers 0.5 --edit-interval 500` | +| wide editing | `--mode wide --clients 3000 --writers 0.3` | +| reconnect storm | any of the above with `--storm-at 300` — or restart yhub during the hold: the clients reconnect on their own, as the frontend does | + +Several swarms can run at once (one per pod) against the same manifest, each +with a distinct slice of it: split the manifest's `sessions` beforehand. The +propagation latency compares the clocks of the writer and of the reader, which +is exact inside one process and only as good as the clock sync between pods. + +## What it measures + +On `/metrics`, and summarised as percentiles in the report: + +| Metric | What it is | +| ------ | ---------- | +| `swarm_clients{state}` | Clients by socket state | +| `swarm_connect_duration_seconds` | From asking for a connection to the socket being open. yhub authenticates the upgrade against the backend, and seeds the document under soft migration, before answering: this is what that costs | +| `swarm_sync_duration_seconds` | From the socket being open to the first sync of the document | +| `swarm_propagation_latency_seconds` | From an edit to its arrival on another client of the document | +| `swarm_edits_total`, `swarm_updates_received_total` | Edits made, updates received | +| `swarm_ws_messages_total{direction}`, `swarm_ws_bytes_total{direction}` | Websocket traffic | +| `swarm_reconnects_total`, `swarm_ws_closes_total{code}` | Reconnections, and why the sockets closed | +| `swarm_upgrade_failures_total{status}` | Upgrades the server refused, by http status (a `503` is yhub not reaching the backend) | +| `nodejs_*` | The swarm's own runtime: past a point the load generator saturates before the server does, and its event-loop lag says so | + +The report also holds the convergence check: after `--settle` seconds, every +client of a document must hold the same content. Documents that do not are +listed under `convergence.diverged`. + +## What it writes into the documents + +Edits go into a `Y.Text` and a `Y.Map` of the document that the editor does not +render (`loadtest-text`, `loadtest-stamps`): the server persists and fans them +out like any content. A document a swarm wrote to keeps those types in its +history. **Only run this against anonymised data**, never against a document +someone uses. + +## Container + +```bash +docker build -f src/loadtest/swarm/Dockerfile -t docs-swarm . +docker run --rm -v ./manifest.json:/manifest.json:ro docs-swarm \ + --manifest /manifest.json --url wss://docs.example.com --clients 100 +``` + +In a cluster, run it as a Job on nodes that do not host the application, going +through the ingress like a browser would. + +## Development + +```bash +yarn typecheck && yarn lint && yarn test +``` + +The tests run a small y-websocket protocol server in-process +(`__tests__/_server.ts`) — no yhub, no store — and drive a real swarm against +it: cookies, origin, edits, propagation, convergence, storms, refused upgrades. diff --git a/src/loadtest/swarm/__tests__/_server.ts b/src/loadtest/swarm/__tests__/_server.ts new file mode 100644 index 000000000..0f7177dff --- /dev/null +++ b/src/loadtest/swarm/__tests__/_server.ts @@ -0,0 +1,151 @@ +// A y-websocket protocol server small enough to live in a test: one Y.Doc per +// room, sync step 1/2 and awareness relayed between the sockets of a room. It +// records the headers of every upgrade, which is what the swarm is checked on. +import { createServer } from 'node:http'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import * as decoding from 'lib0/decoding'; +import * as encoding from 'lib0/encoding'; +import { WebSocketServer } from 'ws'; +import type WebSocket from 'ws'; +import * as awarenessProtocol from 'y-protocols/awareness'; +import * as syncProtocol from 'y-protocols/sync'; +import * as Y from 'yjs'; + +const MESSAGE_SYNC = 0; +const MESSAGE_AWARENESS = 1; + +interface Room { + doc: Y.Doc; + awareness: awarenessProtocol.Awareness; + sockets: Set; +} + +export interface TestServer { + url: string; + upgrades: Array<{ path: string; cookie?: string; origin?: string }>; + rooms: Map; + refuse: { status: number } | null; + dropAll: () => void; + close: () => Promise; +} + +export const startTestServer = async (): Promise => { + const rooms = new Map(); + const upgrades: TestServer['upgrades'] = []; + const state: { refuse: { status: number } | null } = { refuse: null }; + const http: Server = createServer((_req, res) => res.writeHead(404).end()); + const wss = new WebSocketServer({ noServer: true }); + + const room = (name: string): Room => { + let r = rooms.get(name); + if (!r) { + const doc = new Y.Doc(); + const awareness = new awarenessProtocol.Awareness(doc); + r = { doc, awareness, sockets: new Set() }; + doc.on('update', (update: Uint8Array, origin: unknown) => { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, MESSAGE_SYNC); + syncProtocol.writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + for (const socket of r?.sockets ?? []) { + if (socket !== origin) socket.send(message); + } + }); + awareness.on( + 'update', + ( + { + added, + updated, + removed, + }: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown, + ) => { + const changed = [...added, ...updated, ...removed]; + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, MESSAGE_AWARENESS); + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(awareness, changed), + ); + const message = encoding.toUint8Array(encoder); + for (const socket of r?.sockets ?? []) { + if (socket !== origin) socket.send(message); + } + }, + ); + rooms.set(name, r); + } + return r; + }; + + http.on('upgrade', (req, socket, head) => { + const path = req.url ?? ''; + upgrades.push({ + path, + cookie: req.headers.cookie, + origin: req.headers.origin, + }); + if (state.refuse) { + socket.write( + `HTTP/1.1 ${state.refuse.status} Refused\r\nContent-Length: 0\r\n\r\n`, + ); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + const name = path.split('/').pop() ?? ''; + const r = room(name); + r.sockets.add(ws); + ws.on('message', (data: Buffer) => { + const decoder = decoding.createDecoder(new Uint8Array(data)); + const encoder = encoding.createEncoder(); + const type = decoding.readVarUint(decoder); + if (type === MESSAGE_SYNC) { + encoding.writeVarUint(encoder, MESSAGE_SYNC); + syncProtocol.readSyncMessage(decoder, encoder, r.doc, ws); + if (encoding.length(encoder) > 1) + ws.send(encoding.toUint8Array(encoder)); + } else if (type === MESSAGE_AWARENESS) { + awarenessProtocol.applyAwarenessUpdate( + r.awareness, + decoding.readVarUint8Array(decoder), + ws, + ); + } + }); + ws.on('close', () => r.sockets.delete(ws)); + // sync step 1, as y-websocket's server does on connect + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, MESSAGE_SYNC); + syncProtocol.writeSyncStep1(encoder, r.doc); + ws.send(encoding.toUint8Array(encoder)); + }); + }); + + await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve)); + const { port } = http.address() as AddressInfo; + return { + url: `ws://127.0.0.1:${port}`, + upgrades, + rooms, + get refuse() { + return state.refuse; + }, + set refuse(value) { + state.refuse = value; + }, + dropAll: () => { + for (const r of rooms.values()) + for (const socket of r.sockets) socket.terminate(); + }, + close: () => + new Promise((resolve) => { + for (const r of rooms.values()) + for (const socket of r.sockets) socket.terminate(); + wss.close(() => http.close(() => resolve())); + }), + }; +}; diff --git a/src/loadtest/swarm/__tests__/config.spec.ts b/src/loadtest/swarm/__tests__/config.spec.ts new file mode 100644 index 000000000..7fbfc949b --- /dev/null +++ b/src/loadtest/swarm/__tests__/config.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { parseConfig } from '../src/config.js'; + +const base = ['--manifest', 'm.json', '--url', 'wss://docs.example.com/']; + +describe('parseConfig', () => { + it('has the frontend-like defaults', () => { + const config = parseConfig(base); + expect(config.url).toBe('wss://docs.example.com'); + expect(config.origin).toBe('https://docs.example.com'); + expect(config.org).toBe('docs'); + expect(config.mode).toBe('wide'); + expect(config.writers).toBe(0.3); + expect(config.reconnectJitter).toBe(3000); + expect(config.metricsPort).toBe(9465); + }); + + it('reads every option', () => { + const config = parseConfig([ + ...base, + '--clients', + '500', + '--ramp', + '25', + '--duration', + '300', + '--mode', + 'hot', + '--doc', + 'd1', + '--writers', + '0.5', + '--edit-interval', + '500', + '--edit-size', + '3', + '--awareness-interval', + '0', + '--storm-at', + '120', + '--settle', + '5', + '--metrics-port', + '0', + '--metrics-token', + 't', + '--origin', + 'http://other', + ]); + expect(config).toMatchObject({ + clients: 500, + ramp: 25, + duration: 300, + mode: 'hot', + doc: 'd1', + writers: 0.5, + editInterval: 500, + editSize: 3, + awarenessInterval: 0, + stormAt: 120, + settle: 5, + metricsPort: 0, + metricsToken: 't', + origin: 'http://other', + }); + }); + + it('nobody writes in idle mode unless asked', () => { + expect(parseConfig([...base, '--mode', 'idle']).writers).toBe(0); + expect( + parseConfig([...base, '--mode', 'idle', '--writers', '0.1']).writers, + ).toBe(0.1); + }); + + it.each([ + [['--url', 'wss://x'], '--manifest is required'], + [['--manifest', 'm'], '--url is required'], + [['--manifest', 'm', '--url', 'https://x'], 'must be ws:// or wss://'], + [[...base, '--mode', 'other'], '--mode must be one of'], + [[...base, '--writers', '2'], '--writers must be a number between 0 and 1'], + [[...base, '--clients', 'abc'], '--clients must be a number'], + [[...base, '--unknown'], 'Unknown option'], + ])('refuses %j', (argv, message) => { + expect(() => parseConfig(argv)).toThrow(message); + }); +}); diff --git a/src/loadtest/swarm/__tests__/manifest.spec.ts b/src/loadtest/swarm/__tests__/manifest.spec.ts new file mode 100644 index 000000000..3748b1acf --- /dev/null +++ b/src/loadtest/swarm/__tests__/manifest.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { parseManifest } from '../src/manifest.js'; + +const valid = { + cookie_name: 'docs_sessionid', + public_documents: ['p1'], + sessions: [ + { + user_id: 'u1', + session_key: 'k1', + editable_documents: ['e1'], + readonly_documents: ['r1'], + }, + ], +}; + +describe('parseManifest', () => { + it('reads what the backend writes', () => { + const manifest = parseManifest(JSON.stringify(valid)); + expect(manifest.cookie_name).toBe('docs_sessionid'); + expect(manifest.sessions[0]).toEqual(valid.sessions[0]); + expect(manifest.public_documents).toEqual(['p1']); + }); + + it('fills what is missing rather than failing on it', () => { + const manifest = parseManifest( + JSON.stringify({ cookie_name: 'c', sessions: [{ session_key: 'k' }] }), + ); + expect(manifest.sessions[0]).toEqual({ + user_id: '0', + session_key: 'k', + editable_documents: [], + readonly_documents: [], + }); + expect(manifest.public_documents).toEqual([]); + }); + + it.each([ + ['not json', 'not JSON'], + ['null', 'not an object'], + ['[]', 'no cookie_name'], + ['{"sessions":[{"session_key":"k"}]}', 'no cookie_name'], + ['{"cookie_name":"c","sessions":[]}', 'holds no session'], + ['{"cookie_name":"c","sessions":[{"user_id":"u"}]}', 'no session_key'], + ])('refuses %s', (text, message) => { + expect(() => parseManifest(text)).toThrow(message); + }); + + it('refuses sessions that have expired', () => { + expect(() => + parseManifest( + JSON.stringify({ ...valid, expires_at: '2000-01-01T00:00:00+00:00' }), + ), + ).toThrow('expired'); + expect(() => + parseManifest( + JSON.stringify({ ...valid, expires_at: '2999-01-01T00:00:00+00:00' }), + ), + ).not.toThrow(); + }); +}); diff --git a/src/loadtest/swarm/__tests__/stats.spec.ts b/src/loadtest/swarm/__tests__/stats.spec.ts new file mode 100644 index 000000000..799110033 --- /dev/null +++ b/src/loadtest/swarm/__tests__/stats.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { Samples } from '../src/stats.js'; + +describe('Samples', () => { + it('summarises nothing as null', () => { + expect(new Samples().summary()).toBeNull(); + }); + + it('computes the percentiles', () => { + const samples = new Samples(); + for (let i = 1; i <= 100; i++) samples.add(i); + expect(samples.summary()).toEqual({ + count: 100, + min: 1, + p50: 50, + p95: 95, + p99: 99, + max: 100, + mean: 50.5, + }); + }); +}); diff --git a/src/loadtest/swarm/__tests__/swarm.spec.ts b/src/loadtest/swarm/__tests__/swarm.spec.ts new file mode 100644 index 000000000..51556674b --- /dev/null +++ b/src/loadtest/swarm/__tests__/swarm.spec.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { parseConfig } from '../src/config.js'; +import type { Config } from '../src/config.js'; +import type { Manifest } from '../src/manifest.js'; +import { registry } from '../src/metrics.js'; +import { Swarm, assign } from '../src/swarm.js'; +import { startTestServer } from './_server.js'; +import type { TestServer } from './_server.js'; + +const manifest: Manifest = { + cookie_name: 'docs_sessionid', + public_documents: ['pub1', 'pub2'], + sessions: [ + { + user_id: 'u1', + session_key: 'k1', + editable_documents: ['e1', 'e2'], + readonly_documents: ['r1'], + }, + { + user_id: 'u2', + session_key: 'k2', + editable_documents: ['e3'], + readonly_documents: [], + }, + { + user_id: 'u3', + session_key: 'k3', + editable_documents: [], + readonly_documents: ['r2'], + }, + ], +}; + +const config = (url: string, ...extra: string[]): Config => + parseConfig([ + '--manifest', + 'm', + '--url', + url, + '--metrics-port', + '0', + '--progress-interval', + '1', + '--ramp', + '1000', + '--settle', + '1', + ...extra, + ]); + +describe('assign', () => { + const dummy = (...extra: string[]) => config('ws://x', ...extra); + + it('puts everybody on one document in hot mode, the first public one by default', () => { + const { assignments } = assign( + dummy('--mode', 'hot', '--clients', '4'), + manifest, + ); + expect(assignments.map((a) => a.room)).toEqual([ + 'pub1', + 'pub1', + 'pub1', + 'pub1', + ]); + expect( + assign(dummy('--mode', 'hot', '--doc', 'd', '--clients', '1'), manifest) + .assignments[0].room, + ).toBe('d'); + }); + + it('gives writers a document they may edit and readers one they may read', () => { + const { assignments } = assign( + dummy('--mode', 'wide', '--clients', '3', '--writers', '0.67'), + manifest, + ); + expect( + assignments.map((a) => [a.session.user_id, a.writer, a.room]), + ).toEqual([ + ['u1', true, 'e1'], + ['u2', true, 'e3'], + ['u3', false, 'r2'], + ]); + }); + + it('falls back to the public documents for a user with none, and warns past the sessions', () => { + const { assignments, warnings } = assign( + dummy('--mode', 'wide', '--clients', '4', '--writers', '1'), + manifest, + ); + // u3 may edit nothing of their own: a public document instead + expect(assignments[2].room).toBe('pub1'); + // 4 clients for 3 sessions: u1 twice, on their next document + expect(assignments[3].session.user_id).toBe('u1'); + expect(assignments[3].room).toBe('e2'); + expect(warnings[0]).toContain('4 clients for 3 sessions'); + }); + + it('refuses hot mode without a document', () => { + expect(() => + assign(dummy('--mode', 'hot'), { ...manifest, public_documents: [] }), + ).toThrow('--mode hot needs --doc'); + }); +}); + +describe('Swarm', () => { + let server: TestServer; + beforeEach(async () => { + server = await startTestServer(); + }); + afterEach(async () => { + await server.close(); + registry.resetMetrics(); + }); + + it('connects as the users of the manifest, edits, and converges', async () => { + const swarm = new Swarm( + config( + server.url, + '--mode', + 'hot', + '--clients', + '6', + '--writers', + '0.5', + '--duration', + '3', + '--edit-interval', + '100', + '--awareness-interval', + '200', + ), + manifest, + ); + const report = await swarm.run(); + + expect(report.clients).toBe(6); + expect(report.connected).toBe(6); + expect(report.writers).toBe(3); + expect(report.edits).toBeGreaterThan(20); + expect(report.convergence).toEqual({ + documents: 1, + converged: 1, + diverged: [], + }); + // a sample per edit per other client, roughly + expect(report.propagation?.count).toBeGreaterThan(report.edits); + expect(report.propagation?.p95).toBeLessThan(1); + expect(report.connect?.count).toBe(6); + expect(report.sync?.count).toBe(6); + expect(report.reconnects).toBe(0); + + // the upgrade carried the session cookie, the origin and the room + expect(server.upgrades).toHaveLength(6); + expect(server.upgrades[0]).toEqual({ + path: '/collaboration/ws/v1/docs/pub1', + cookie: 'docs_sessionid=k1', + origin: `http://${new URL(server.url).host}`, + }); + expect(new Set(server.upgrades.map((u) => u.cookie))).toEqual( + new Set(['docs_sessionid=k1', 'docs_sessionid=k2', 'docs_sessionid=k3']), + ); + // the server holds what the writers typed + const doc = server.rooms.get('pub1')?.doc; + expect(doc?.getText('loadtest-text').length).toBeGreaterThan(0); + expect(doc?.getMap('loadtest-stamps').size).toBe(3); + // and saw their awareness + expect( + server.rooms.get('pub1')?.awareness.getStates().size ?? 0, + ).toBeGreaterThanOrEqual(0); + + const text = await registry.metrics(); + expect(text).toMatch(/swarm_edits_total \d+/); + expect(text).toMatch(/swarm_propagation_latency_seconds_count \d+/); + expect(text).toMatch(/swarm_ws_bytes_total\{direction="out"\} [1-9]/); + expect(text).toMatch(/swarm_ws_bytes_total\{direction="in"\} [1-9]/); + }); + + it('reconnects through a storm and when the server drops everybody', async () => { + const swarm = new Swarm( + config( + server.url, + '--mode', + 'wide', + '--clients', + '3', + '--writers', + '0', + '--duration', + '3', + '--storm-at', + '1', + '--reconnect-jitter', + '200', + ), + manifest, + ); + const dropAt = setTimeout(() => server.dropAll(), 2200); + const report = await swarm.run(); + clearTimeout(dropAt); + + expect(report.connected).toBe(3); + // one reconnection from the storm, one from the drop + expect(report.reconnects).toBeGreaterThanOrEqual(6); + expect(server.upgrades.length).toBeGreaterThanOrEqual(9); + const text = await registry.metrics(); + expect(text).toMatch( + /swarm_reconnects_total [6-9]|swarm_reconnects_total \d{2,}/, + ); + expect(text).toMatch(/swarm_ws_closes_total\{code="\d+"\}/); + }); + + it('counts the upgrades a server refuses, and keeps trying', async () => { + server.refuse = { status: 503 }; + const swarm = new Swarm( + config( + server.url, + '--mode', + 'hot', + '--clients', + '2', + '--duration', + '2', + '--settle', + '0', + ), + manifest, + ); + const report = await swarm.run(); + + expect(report.connected).toBe(0); + expect(report.convergence.documents).toBe(0); + const text = await registry.metrics(); + expect(text).toMatch(/swarm_upgrade_failures_total\{status="503"\} [2-9]/); + // every client was let go at the end of the run + expect(text).toMatch(/swarm_clients\{state="connecting"\} 0/); + }); +}); diff --git a/src/loadtest/swarm/eslint.config.mjs b/src/loadtest/swarm/eslint.config.mjs new file mode 100644 index 000000000..6c91b6a90 --- /dev/null +++ b/src/loadtest/swarm/eslint.config.mjs @@ -0,0 +1,24 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier/flat'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }, + ], + }, + }, + prettier, +); diff --git a/src/loadtest/swarm/package.json b/src/loadtest/swarm/package.json new file mode 100644 index 000000000..0b07226ab --- /dev/null +++ b/src/loadtest/swarm/package.json @@ -0,0 +1,35 @@ +{ + "name": "docs-loadtest-swarm", + "private": true, + "type": "module", + "description": "Load generator opening many websocket clients on the Docs collaboration server, with the client stack the frontend uses", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "start": "node dist/index.js", + "lint": "eslint", + "test": "vitest run" + }, + "engines": { + "node": ">=22" + }, + "packageManager": "yarn@1.22.22", + "dependencies": { + "lib0": "0.2.114", + "prom-client": "15.1.3", + "ws": "8.18.3", + "y-protocols": "1.0.7", + "y-websocket": "3.1.0", + "yjs": "13.6.32" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/node": "24.13.3", + "@types/ws": "8.18.1", + "eslint": "10.9.0", + "eslint-config-prettier": "10.1.8", + "typescript": "6.0.3", + "typescript-eslint": "8.69.0", + "vitest": "3.2.7" + } +} diff --git a/src/loadtest/swarm/src/client.ts b/src/loadtest/swarm/src/client.ts new file mode 100644 index 000000000..21be1f4b6 --- /dev/null +++ b/src/loadtest/swarm/src/client.ts @@ -0,0 +1,291 @@ +/** + * One virtual client: a Y.Doc and the very provider the frontend uses + * (`y-websocket`'s WebsocketProvider, same options), over a `ws` socket that + * carries the session cookie and the origin a browser would send. + * + * Edits go into a Y.Text and a Y.Map of the document that the editor does not + * render (`loadtest-text`, `loadtest-stamps`): the server persists and fans them + * out like any content, without the swarm having to build BlockNote blocks. A + * document a swarm wrote to keeps those types in its history — only run this + * on anonymised data. + */ +import { WebsocketProvider } from 'y-websocket'; +import * as Y from 'yjs'; +import WebSocket from 'ws'; +import type { ClientOptions as SocketOptions } from 'ws'; + +import * as metrics from './metrics.js'; +import type { Samples } from './stats.js'; + +export interface ClientOptions { + id: number; + url: string; + origin: string; + room: string; + cookie: string; + writer: boolean; + editInterval: number; + editSize: number; + awarenessInterval: number; + samples: { + connect: Samples; + sync: Samples; + propagation: Samples; + }; +} + +const STAMPS = 'loadtest-stamps'; +const TEXT = 'loadtest-text'; +const TYPED = 'abcdefghijklmnopqrstuvwxyz '; + +// The polyfill y-websocket instantiates with `(url, protocols)`: the headers +// ride in through the class, one per client since each has its own cookie. +const socketClass = ( + cookie: string, + origin: string, + onUpgradeFailure: (status: number) => void, +) => + class SwarmSocket extends WebSocket { + constructor(url: string, protocols?: string | string[]) { + const options: SocketOptions = { + headers: { cookie, origin }, + // a bad certificate on preprod is not what is being tested + rejectUnauthorized: false, + }; + super(url, protocols, options); + this.on('unexpected-response', (_req, res) => { + onUpgradeFailure(res.statusCode ?? 0); + res.resume(); + }); + } + }; + +export class Client { + readonly doc = new Y.Doc(); + readonly options: ClientOptions; + provider: WebsocketProvider | null = null; + state: 'idle' | 'connecting' | 'connected' | 'disconnected' = 'idle'; + connections = 0; + edits = 0; + private editTimer: NodeJS.Timeout | null = null; + private awarenessTimer: NodeJS.Timeout | null = null; + private connectAskedAt = 0; + private connectedAt = 0; + private synced = false; + private seq = 0; + private seen = new Map(); + + constructor(options: ClientOptions) { + this.options = options; + this.doc.getMap<{ seq: number; ts: number }>(STAMPS).observe((event) => { + // every key the event carries was set by another client (own changes + // arrive here too, but with this doc as their origin) + if (event.transaction.origin === this) return; + const now = Date.now(); + for (const key of event.keysChanged) { + const stamp = event.target.get(key); + if (!stamp || key === `w${this.options.id}`) continue; + // a stamp written before this client synced is history, not latency + if ((this.seen.get(key) ?? -1) >= stamp.seq) continue; + if (this.seen.has(key) || this.synced) { + const latency = (now - stamp.ts) / 1000; + if (latency >= 0 && latency < 3600) { + metrics.propagationLatency.observe(latency); + this.options.samples.propagation.add(latency); + } + } + this.seen.set(key, stamp.seq); + } + }); + this.doc.on('update', (update: Uint8Array, origin: unknown) => { + if (origin !== this) metrics.updatesReceivedTotal.inc(); + void update; + }); + } + + connect(): void { + if (this.provider) return; + this.setState('connecting'); + this.connectAskedAt = Date.now(); + this.synced = false; + const provider = new WebsocketProvider( + this.options.url, + this.options.room, + this.doc, + { + WebSocketPolyfill: socketClass( + this.options.cookie, + this.options.origin, + (status) => { + metrics.upgradeFailuresTotal.inc({ status: String(status) }); + }, + ) as unknown as typeof globalThis.WebSocket, + // the frontend's options, see useProviderStore.tsx + disableBc: true, + maxBackoffTime: 30000, + resyncInterval: 20000, + }, + ); + provider.on('status', ({ status }) => { + if (status === 'connected') { + this.connectedAt = Date.now(); + this.connections += 1; + if (this.connections > 1) metrics.reconnectsTotal.inc(); + else { + const seconds = (this.connectedAt - this.connectAskedAt) / 1000; + metrics.connectDuration.observe(seconds); + this.options.samples.connect.add(seconds); + } + this.setState('connected'); + this.watchSocket(provider); + } else if (status === 'disconnected') { + this.setState('disconnected'); + this.synced = false; + // the provider reconnects on its own: what is measured is the next open + this.connectAskedAt = Date.now(); + } else { + this.setState('connecting'); + } + }); + provider.on('sync', (isSynced: boolean) => { + if (!isSynced || this.synced) return; + this.synced = true; + const seconds = (Date.now() - this.connectedAt) / 1000; + metrics.syncDuration.observe(seconds); + this.options.samples.sync.add(seconds); + if (this.options.writer) this.startWriting(); + }); + provider.on('connection-close', (event) => { + metrics.closesTotal.inc({ code: String(event?.code ?? 'none') }); + }); + provider.on('connection-error', () => { + metrics.errorsTotal.inc({ kind: 'connection' }); + }); + this.provider = provider; + } + + private watchSocket(provider: WebsocketProvider): void { + const ws = provider.ws as unknown as WebSocket | null; + if (!ws || (ws as unknown as { swarmWatched?: boolean }).swarmWatched) + return; + (ws as unknown as { swarmWatched?: boolean }).swarmWatched = true; + ws.on('message', (data: Buffer | ArrayBuffer | Buffer[]) => { + metrics.messagesTotal.inc({ direction: 'in' }); + metrics.bytesTotal.inc({ direction: 'in' }, byteLength(data)); + }); + const send = ws.send.bind(ws); + (ws as unknown as { send: typeof ws.send }).send = (( + data: Parameters[0], + ...rest: unknown[] + ) => { + metrics.messagesTotal.inc({ direction: 'out' }); + metrics.bytesTotal.inc({ direction: 'out' }, byteLength(data as Buffer)); + return (send as (...args: unknown[]) => void)(data, ...rest); + }) as typeof ws.send; + } + + private startWriting(): void { + if (this.editTimer) return; + const { editInterval, awarenessInterval } = this.options; + // spread the writers over the interval rather than firing them together + const first = Math.random() * editInterval; + this.editTimer = setTimeout(() => { + this.edit(); + this.editTimer = setInterval(() => this.edit(), editInterval); + }, first); + if (awarenessInterval > 0) { + this.awarenessTimer = setInterval( + () => this.awareness(), + awarenessInterval, + ); + } + } + + private edit(): void { + if (this.state !== 'connected') return; + this.seq += 1; + const text = this.doc.getText(TEXT); + const chunk = Array.from( + { length: this.options.editSize }, + () => TYPED[Math.floor(Math.random() * TYPED.length)], + ).join(''); + this.doc.transact(() => { + text.insert(text.length, chunk); + this.doc + .getMap(STAMPS) + .set(`w${this.options.id}`, { seq: this.seq, ts: Date.now() }); + }, this); + this.edits += 1; + metrics.editsTotal.inc(); + } + + private awareness(): void { + if (this.state !== 'connected' || !this.provider) return; + this.provider.awareness.setLocalStateField('cursor', { + // what the editor broadcasts, roughly: a position that moves + anchor: this.doc.getText(TEXT).length, + head: this.doc.getText(TEXT).length, + ts: Date.now(), + }); + } + + /** Drop the socket and let the provider reconnect, after `jitter` ms. */ + storm(jitter: number): void { + const provider = this.provider; + if (!provider) return; + provider.disconnect(); + this.setState('disconnected'); + this.synced = false; + setTimeout(() => { + this.connectAskedAt = Date.now(); + provider.connect(); + }, Math.random() * jitter); + } + + /** Stop editing; the connection stays, so that the last updates land. */ + stopWriting(): void { + // one handle covers both the initial timeout and the interval that + // replaces it: clearing it as both is harmless + if (this.editTimer) { + clearTimeout(this.editTimer); + clearInterval(this.editTimer); + } + if (this.awarenessTimer) clearInterval(this.awarenessTimer); + this.editTimer = null; + this.awarenessTimer = null; + this.options.writer = false; + } + + /** Close for good. */ + destroy(): void { + this.stopWriting(); + this.provider?.destroy(); + this.provider = null; + this.setState('idle'); + } + + /** What this client holds, for the convergence check. */ + fingerprint(): string { + const stamps = this.doc.getMap<{ seq: number }>(STAMPS); + const entries = [...stamps.entries()] + .map(([key, value]) => `${key}=${value.seq}`) + .sort() + .join(','); + return `${this.doc.getText(TEXT).length}|${entries}`; + } + + private setState(state: Client['state']): void { + if (state === this.state) return; + if (this.state !== 'idle') + metrics.clientsByState.dec({ state: this.state }); + this.state = state; + if (state !== 'idle') metrics.clientsByState.inc({ state }); + } +} + +const byteLength = (data: Buffer | ArrayBuffer | Buffer[] | string): number => { + if (typeof data === 'string') return Buffer.byteLength(data); + if (Array.isArray(data)) + return data.reduce((sum, chunk) => sum + chunk.byteLength, 0); + return data.byteLength; +}; diff --git a/src/loadtest/swarm/src/config.ts b/src/loadtest/swarm/src/config.ts new file mode 100644 index 000000000..b2dadc9bb --- /dev/null +++ b/src/loadtest/swarm/src/config.ts @@ -0,0 +1,153 @@ +/** + * Everything the swarm is told, from the command line, validated once here. + * + * swarm --manifest m.json --url wss://docs.example.com --clients 500 \ + * --mode wide --duration 300 --ramp 20 --writers 0.3 + */ +import { parseArgs } from 'node:util'; + +export type Mode = 'idle' | 'hot' | 'wide'; + +export interface Config { + manifest: string; + /** Base of the websocket url, `wss://host` — the room path is appended. */ + url: string; + /** The `Origin` header, which yhub checks against its allowlist. */ + origin: string; + org: string; + clients: number; + /** Connections opened per second while ramping up. */ + ramp: number; + /** Seconds to hold once every client is connected. */ + duration: number; + mode: Mode; + /** `hot`: the one document everybody opens. Defaults to the first public one. */ + doc?: string; + /** Share of the clients that edit, the rest only read. */ + writers: number; + /** Milliseconds between two edits of a writer. */ + editInterval: number; + /** Characters typed per edit. */ + editSize: number; + /** Milliseconds between two awareness updates of a writer, 0 for none. */ + awarenessInterval: number; + /** Second of the hold at which every client disconnects and reconnects, 0 for never. */ + stormAt: number; + /** Maximum jitter, in milliseconds, before a client reconnects in a storm. */ + reconnectJitter: number; + /** Seconds to wait for the documents to converge once the edits stop. */ + settle: number; + metricsPort: number; + /** Bearer token the metrics listener requires, empty for none. */ + metricsToken: string; + /** Where the final report is written, `-` for stdout. */ + report: string; + /** Seconds between two progress lines on stderr. */ + progressInterval: number; +} + +const MODES: Mode[] = ['idle', 'hot', 'wide']; + +const number = ( + name: string, + raw: string | undefined, + dflt: number, + min: number, + max = Infinity, +): number => { + if (raw === undefined || raw === '') return dflt; + const value = Number(raw); + if (!Number.isFinite(value) || value < min || value > max) { + throw new Error( + `--${name} must be a number between ${min} and ${max} (got "${raw}")`, + ); + } + return value; +}; + +export const parseConfig = (argv: string[]): Config => { + const { values } = parseArgs({ + args: argv, + options: { + manifest: { type: 'string' }, + url: { type: 'string' }, + origin: { type: 'string' }, + org: { type: 'string', default: 'docs' }, + clients: { type: 'string' }, + ramp: { type: 'string' }, + duration: { type: 'string' }, + mode: { type: 'string', default: 'wide' }, + doc: { type: 'string' }, + writers: { type: 'string' }, + 'edit-interval': { type: 'string' }, + 'edit-size': { type: 'string' }, + 'awareness-interval': { type: 'string' }, + 'storm-at': { type: 'string' }, + 'reconnect-jitter': { type: 'string' }, + settle: { type: 'string' }, + 'metrics-port': { type: 'string' }, + 'metrics-token': { type: 'string', default: '' }, + report: { type: 'string', default: '-' }, + 'progress-interval': { type: 'string' }, + }, + strict: true, + }); + if (!values.manifest) throw new Error('--manifest is required'); + if (!values.url) throw new Error('--url is required (wss://host)'); + let url: URL; + try { + url = new URL(values.url); + } catch { + throw new Error(`--url is not a url (got "${values.url}")`); + } + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new Error(`--url must be ws:// or wss:// (got "${values.url}")`); + } + const mode = values.mode as Mode; + if (!MODES.includes(mode)) { + throw new Error( + `--mode must be one of ${MODES.join(', ')} (got "${values.mode}")`, + ); + } + // what a browser on the application would send: the same host, over http(s) + const origin = + values.origin ?? + `${url.protocol === 'wss:' ? 'https' : 'http'}://${url.host}`; + return { + manifest: values.manifest, + url: values.url.replace(/\/+$/, ''), + origin, + org: values.org ?? 'docs', + clients: number('clients', values.clients, 10, 1), + ramp: number('ramp', values.ramp, 10, 0.1), + duration: number('duration', values.duration, 60, 0), + mode, + doc: values.doc, + writers: number('writers', values.writers, mode === 'idle' ? 0 : 0.3, 0, 1), + editInterval: number('edit-interval', values['edit-interval'], 2000, 10), + editSize: number('edit-size', values['edit-size'], 12, 1, 100000), + awarenessInterval: number( + 'awareness-interval', + values['awareness-interval'], + 5000, + 0, + ), + stormAt: number('storm-at', values['storm-at'], 0, 0), + reconnectJitter: number( + 'reconnect-jitter', + values['reconnect-jitter'], + 3000, + 0, + ), + settle: number('settle', values.settle, 15, 0), + metricsPort: number('metrics-port', values['metrics-port'], 9465, 0, 65535), + metricsToken: values['metrics-token'] ?? '', + report: values.report ?? '-', + progressInterval: number( + 'progress-interval', + values['progress-interval'], + 10, + 1, + ), + }; +}; diff --git a/src/loadtest/swarm/src/index.ts b/src/loadtest/swarm/src/index.ts new file mode 100644 index 000000000..96dd27cbf --- /dev/null +++ b/src/loadtest/swarm/src/index.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * The command line. Progress goes to stderr, the report to `--report` (stdout + * by default), the metrics to `--metrics-port`. + */ +import { writeFileSync } from 'node:fs'; + +import { parseConfig } from './config.js'; +import { readManifest } from './manifest.js'; +import { startMetricsServer } from './metrics.js'; +import { Swarm } from './swarm.js'; + +const log = (line: string) => + process.stderr.write(`${new Date().toISOString()} ${line}\n`); + +const main = async (): Promise => { + // y-websocket registers one `exit` listener per provider, and Node warns past + // ten of them: thousands are expected here + process.setMaxListeners(0); + const config = parseConfig(process.argv.slice(2)); + const manifest = readManifest(config.manifest); + if (config.metricsPort > 0) { + await startMetricsServer(config.metricsPort, config.metricsToken); + log(`metrics on :${config.metricsPort}/metrics`); + } + const swarm = new Swarm(config, manifest); + process.on('SIGINT', () => { + log('interrupted: stopping'); + swarm.stop(); + }); + process.on('SIGTERM', () => swarm.stop()); + log( + `starting: ${config.clients} clients, mode ${config.mode}, ramp ${config.ramp}/s, ` + + `hold ${config.duration}s, writers ${config.writers}`, + ); + const report = await swarm.run(log); + const text = JSON.stringify(report, null, 2); + if (config.report === '-') process.stdout.write(`${text}\n`); + else writeFileSync(config.report, text); + for (const warning of report.warnings) log(`warning: ${warning}`); + log( + `done: ${report.connected}/${report.clients} connected, ${report.edits} edits, ` + + `${report.convergence.converged}/${report.convergence.documents} documents converged`, + ); + return report.convergence.diverged.length === 0 && + report.connected === report.clients + ? 0 + : 1; +}; + +main().then( + (code) => process.exit(code), + (err) => { + process.stderr.write(`${err instanceof Error ? err.message : err}\n`); + process.exit(2); + }, +); diff --git a/src/loadtest/swarm/src/manifest.ts b/src/loadtest/swarm/src/manifest.ts new file mode 100644 index 000000000..6bd60958f --- /dev/null +++ b/src/loadtest/swarm/src/manifest.ts @@ -0,0 +1,80 @@ +/** + * The manifest `create_load_test_sessions` writes (src/backend/loadtest): which + * cookie to send, and which documents each of the logged-in users may open. + */ +import { readFileSync } from 'node:fs'; + +export interface Session { + user_id: string; + session_key: string; + editable_documents: string[]; + readonly_documents: string[]; +} + +export interface Manifest { + cookie_name: string; + expires_at?: string; + public_documents: string[]; + sessions: Session[]; +} + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === 'string'); + +export const parseManifest = (text: string): Manifest => { + let data: unknown; + try { + data = JSON.parse(text); + } catch (err) { + throw new Error( + `The manifest is not JSON: ${err instanceof Error ? err.message : err}`, + { cause: err }, + ); + } + if (typeof data !== 'object' || data === null) { + throw new Error('The manifest is not an object'); + } + const manifest = data as Record; + if (typeof manifest.cookie_name !== 'string' || !manifest.cookie_name) { + throw new Error('The manifest has no cookie_name'); + } + if (!Array.isArray(manifest.sessions) || manifest.sessions.length === 0) { + throw new Error('The manifest holds no session'); + } + const sessions = manifest.sessions.map((session, index): Session => { + const s = session as Record; + if (typeof s.session_key !== 'string' || !s.session_key) { + throw new Error(`Session #${index} has no session_key`); + } + return { + user_id: typeof s.user_id === 'string' ? s.user_id : String(index), + session_key: s.session_key, + editable_documents: isStringArray(s.editable_documents) + ? s.editable_documents + : [], + readonly_documents: isStringArray(s.readonly_documents) + ? s.readonly_documents + : [], + }; + }); + if (manifest.expires_at !== undefined) { + const expires = Date.parse(String(manifest.expires_at)); + if (!Number.isNaN(expires) && expires < Date.now()) { + throw new Error( + `The sessions of the manifest expired at ${manifest.expires_at}`, + ); + } + } + return { + cookie_name: manifest.cookie_name, + expires_at: + typeof manifest.expires_at === 'string' ? manifest.expires_at : undefined, + public_documents: isStringArray(manifest.public_documents) + ? manifest.public_documents + : [], + sessions, + }; +}; + +export const readManifest = (path: string): Manifest => + parseManifest(readFileSync(path, 'utf8')); diff --git a/src/loadtest/swarm/src/metrics.ts b/src/loadtest/swarm/src/metrics.ts new file mode 100644 index 000000000..735068b56 --- /dev/null +++ b/src/loadtest/swarm/src/metrics.ts @@ -0,0 +1,145 @@ +/** + * What the swarm measures about itself, served on /metrics for the Prometheus of + * the campaign and summarised in the final report. + * + * Every client-side number the plan asks for is here: time to connect, time to + * the first sync, edit propagation latency (a writer stamps the time into the + * document, every other client of that document measures the delay when it + * arrives), reconnections, close codes, bytes. + */ +import { timingSafeEqual } from 'node:crypto'; +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; +import { createServer } from 'node:http'; + +import { + Counter, + Gauge, + Histogram, + Registry, + collectDefaultMetrics, +} from 'prom-client'; + +export const registry = new Registry(); + +const LATENCY_BUCKETS = [ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, +]; + +export const clientsByState = new Gauge({ + name: 'swarm_clients', + help: 'Virtual clients, by the state of their websocket', + labelNames: ['state'] as const, + registers: [registry], +}); +export const connectDuration = new Histogram({ + name: 'swarm_connect_duration_seconds', + help: 'Time from asking for a connection to the socket being open', + buckets: LATENCY_BUCKETS, + registers: [registry], +}); +export const syncDuration = new Histogram({ + name: 'swarm_sync_duration_seconds', + help: 'Time from the socket being open to the first sync of the document', + buckets: LATENCY_BUCKETS, + registers: [registry], +}); +export const propagationLatency = new Histogram({ + name: 'swarm_propagation_latency_seconds', + help: 'Time for an edit to reach another client of the same document', + buckets: LATENCY_BUCKETS, + registers: [registry], +}); +export const editsTotal = new Counter({ + name: 'swarm_edits_total', + help: 'Edits the writers made', + registers: [registry], +}); +export const updatesReceivedTotal = new Counter({ + name: 'swarm_updates_received_total', + help: 'Document updates received from the server', + registers: [registry], +}); +export const messagesTotal = new Counter({ + name: 'swarm_ws_messages_total', + help: 'Websocket messages, by direction', + labelNames: ['direction'] as const, + registers: [registry], +}); +export const bytesTotal = new Counter({ + name: 'swarm_ws_bytes_total', + help: 'Websocket payload bytes, by direction', + labelNames: ['direction'] as const, + registers: [registry], +}); +export const reconnectsTotal = new Counter({ + name: 'swarm_reconnects_total', + help: 'Connections opened after the first one of a client', + registers: [registry], +}); +export const closesTotal = new Counter({ + name: 'swarm_ws_closes_total', + help: 'Websocket closes, by close code', + labelNames: ['code'] as const, + registers: [registry], +}); +export const errorsTotal = new Counter({ + name: 'swarm_errors_total', + help: 'Errors, by kind', + labelNames: ['kind'] as const, + registers: [registry], +}); +export const upgradeFailuresTotal = new Counter({ + name: 'swarm_upgrade_failures_total', + help: 'Websocket upgrades refused, by http status', + labelNames: ['status'] as const, + registers: [registry], +}); + +let defaultsCollected = false; + +const authorized = (req: IncomingMessage, token: string): boolean => { + if (!token) return true; + const presented = Buffer.from(req.headers.authorization ?? ''); + const expected = Buffer.from(`Bearer ${token}`); + return ( + presented.length === expected.length && timingSafeEqual(presented, expected) + ); +}; + +export const handleMetricsRequest = async ( + req: IncomingMessage, + res: ServerResponse, + token: string, +): Promise => { + if ((req.url ?? '').split('?')[0] !== '/metrics') { + res.writeHead(404).end(); + return; + } + if (!authorized(req, token)) { + res.writeHead(401, { 'www-authenticate': 'Bearer' }).end(); + return; + } + res + .writeHead(200, { 'content-type': registry.contentType }) + .end(await registry.metrics()); +}; + +export const startMetricsServer = ( + port: number, + token: string, +): Promise => { + if (!defaultsCollected) { + // the event loop lag of the swarm itself: past a point the load generator + // is what is saturated, and its numbers stop meaning anything + collectDefaultMetrics({ register: registry }); + defaultsCollected = true; + } + const server = createServer((req, res) => { + void handleMetricsRequest(req, res, token); + }); + server.unref(); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, () => resolve(server)); + }); +}; diff --git a/src/loadtest/swarm/src/stats.ts b/src/loadtest/swarm/src/stats.ts new file mode 100644 index 000000000..dcd4ced68 --- /dev/null +++ b/src/loadtest/swarm/src/stats.ts @@ -0,0 +1,44 @@ +/** Percentiles of what was measured, for the final report. */ + +export interface Summary { + count: number; + min: number; + p50: number; + p95: number; + p99: number; + max: number; + mean: number; +} + +export class Samples { + private values: number[] = []; + + add(value: number): void { + this.values.push(value); + } + + get count(): number { + return this.values.length; + } + + summary(): Summary | null { + if (this.values.length === 0) return null; + const sorted = [...this.values].sort((a, b) => a - b); + const at = (q: number): number => + sorted[ + Math.min( + sorted.length - 1, + Math.max(0, Math.ceil(q * sorted.length) - 1), + ) + ]; + return { + count: sorted.length, + min: sorted[0], + p50: at(0.5), + p95: at(0.95), + p99: at(0.99), + max: sorted[sorted.length - 1], + mean: sorted.reduce((sum, value) => sum + value, 0) / sorted.length, + }; + } +} diff --git a/src/loadtest/swarm/src/swarm.ts b/src/loadtest/swarm/src/swarm.ts new file mode 100644 index 000000000..9e571b0d5 --- /dev/null +++ b/src/loadtest/swarm/src/swarm.ts @@ -0,0 +1,245 @@ +/** + * The run: which client opens which document as whom, the ramp, the hold, the + * storm if one is asked for, and the convergence check at the end. + */ +import type { Config } from './config.js'; +import { Client } from './client.js'; +import type { Manifest, Session } from './manifest.js'; +import { Samples } from './stats.js'; +import type { Summary } from './stats.js'; + +export interface Assignment { + id: number; + session: Session; + room: string; + writer: boolean; +} + +export interface Report { + config: Omit; + startedAt: string; + endedAt: string; + clients: number; + documents: number; + writers: number; + connected: number; + reconnects: number; + edits: number; + connect: Summary | null; + sync: Summary | null; + propagation: Summary | null; + convergence: { + documents: number; + converged: number; + diverged: string[]; + }; + warnings: string[]; +} + +/** + * Give each virtual client a session and a document. + * + * Sessions are handed out in turn: with more clients than sessions a user is + * logged in from several clients, which the report warns about, the API being + * throttled per user. Documents: + * hot everybody on `config.doc`, else the first public document + * wide writers open a document they may edit, readers one they may read + * (or edit), picked in turn from their own list + * idle like wide, and nobody writes + */ +export const assign = ( + config: Config, + manifest: Manifest, +): { assignments: Assignment[]; warnings: string[] } => { + const warnings: string[] = []; + if (config.clients > manifest.sessions.length) { + warnings.push( + `${config.clients} clients for ${manifest.sessions.length} sessions: some users are logged in several times, and the API throttles per user`, + ); + } + let hotDoc: string | undefined; + if (config.mode === 'hot') { + hotDoc = config.doc ?? manifest.public_documents[0]; + if (!hotDoc) { + throw new Error( + '--mode hot needs --doc, or a public document in the manifest', + ); + } + } + const writersWanted = Math.round(config.clients * config.writers); + const assignments: Assignment[] = []; + let skipped = 0; + for (let id = 0; assignments.length < config.clients; id++) { + if (id >= config.clients + skipped + manifest.sessions.length) break; + const session = manifest.sessions[id % manifest.sessions.length]; + const writer = assignments.filter((a) => a.writer).length < writersWanted; + let room: string | undefined; + if (hotDoc) { + room = hotDoc; + } else { + const own = writer + ? session.editable_documents + : [...session.readonly_documents, ...session.editable_documents]; + const pool = own.length > 0 ? own : manifest.public_documents; + room = pool[Math.floor(id / manifest.sessions.length) % pool.length]; + } + if (!room) { + skipped += 1; + continue; + } + assignments.push({ id: assignments.length, session, room, writer }); + } + if (assignments.length < config.clients) { + warnings.push( + `only ${assignments.length} of ${config.clients} clients could be given a document: the manifest lists too few`, + ); + } + return { assignments, warnings }; +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export class Swarm { + readonly config: Config; + readonly manifest: Manifest; + readonly clients: Client[] = []; + readonly warnings: string[] = []; + private readonly samples = { + connect: new Samples(), + sync: new Samples(), + propagation: new Samples(), + }; + private stopped = false; + + constructor(config: Config, manifest: Manifest) { + this.config = config; + this.manifest = manifest; + } + + /** Ramp up, hold, storm, stop, settle. Resolves with the report. */ + async run(log: (line: string) => void = () => {}): Promise { + const startedAt = new Date(); + const { assignments, warnings } = assign(this.config, this.manifest); + this.warnings.push(...warnings); + const wsBase = `${this.config.url}/collaboration/ws/v1/${this.config.org}`; + for (const a of assignments) { + this.clients.push( + new Client({ + id: a.id, + url: wsBase, + origin: this.config.origin, + room: a.room, + cookie: `${this.manifest.cookie_name}=${a.session.session_key}`, + writer: a.writer, + editInterval: this.config.editInterval, + editSize: this.config.editSize, + awarenessInterval: this.config.awarenessInterval, + samples: this.samples, + }), + ); + } + + const progress = setInterval( + () => log(this.progressLine()), + this.config.progressInterval * 1000, + ); + try { + // ramp: `ramp` connections per second, evenly spaced + const interval = 1000 / this.config.ramp; + for (const client of this.clients) { + if (this.stopped) break; + client.connect(); + await sleep(interval); + } + log(`ramp done: ${this.clients.length} clients asked to connect`); + + // hold + const holdMs = this.config.duration * 1000; + const stormMs = this.config.stormAt * 1000; + if (stormMs > 0 && stormMs < holdMs) { + await this.wait(stormMs); + log( + `storm: every client disconnects and reconnects within ${this.config.reconnectJitter}ms`, + ); + for (const client of this.clients) + client.storm(this.config.reconnectJitter); + await this.wait(holdMs - stormMs); + } else { + await this.wait(holdMs); + } + } finally { + clearInterval(progress); + } + + // stop editing, let the last updates land, then compare + for (const client of this.clients) client.stopWriting(); + log(`hold done, settling ${this.config.settle}s`); + await sleep(this.config.settle * 1000); + const convergence = this.convergence(); + for (const client of this.clients) client.destroy(); + + const { metricsToken: _token, ...config } = this.config; + return { + config, + startedAt: startedAt.toISOString(), + endedAt: new Date().toISOString(), + clients: this.clients.length, + documents: new Set(assignments.map((a) => a.room)).size, + writers: assignments.filter((a) => a.writer).length, + connected: this.clients.filter((c) => c.connections > 0).length, + reconnects: this.clients.reduce( + (sum, c) => sum + Math.max(0, c.connections - 1), + 0, + ), + edits: this.clients.reduce((sum, c) => sum + c.edits, 0), + connect: this.samples.connect.summary(), + sync: this.samples.sync.summary(), + propagation: this.samples.propagation.summary(), + convergence, + warnings: this.warnings, + }; + } + + /** Ask the run to end at the next step. */ + stop(): void { + this.stopped = true; + } + + private async wait(ms: number): Promise { + const until = Date.now() + ms; + while (!this.stopped && Date.now() < until) { + await sleep(Math.min(250, until - Date.now())); + } + } + + /** Do all the clients of a document hold the same content? */ + convergence(): Report['convergence'] { + const byRoom = new Map>(); + for (const client of this.clients) { + if (client.connections === 0) continue; + const fingerprints = byRoom.get(client.options.room) ?? new Set(); + fingerprints.add(client.fingerprint()); + byRoom.set(client.options.room, fingerprints); + } + const diverged = [...byRoom.entries()] + .filter(([, f]) => f.size > 1) + .map(([room]) => room); + return { + documents: byRoom.size, + converged: byRoom.size - diverged.length, + diverged, + }; + } + + progressLine(): string { + const counts = { connecting: 0, connected: 0, disconnected: 0, idle: 0 }; + for (const client of this.clients) counts[client.state] += 1; + const propagation = this.samples.propagation.summary(); + return ( + `clients connected=${counts.connected} connecting=${counts.connecting} ` + + `disconnected=${counts.disconnected} | edits=${this.clients.reduce((s, c) => s + c.edits, 0)} ` + + `| propagation p50=${propagation ? (propagation.p50 * 1000).toFixed(0) : '-'}ms ` + + `p95=${propagation ? (propagation.p95 * 1000).toFixed(0) : '-'}ms (${propagation?.count ?? 0} samples)` + ); + } +} diff --git a/src/loadtest/swarm/tsconfig.build.json b/src/loadtest/swarm/tsconfig.build.json new file mode 100644 index 000000000..4559ad96b --- /dev/null +++ b/src/loadtest/swarm/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/src/loadtest/swarm/tsconfig.json b/src/loadtest/swarm/tsconfig.json new file mode 100644 index 000000000..7bb657bea --- /dev/null +++ b/src/loadtest/swarm/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2023"], + // the tool runs the compiled JS straight through Node's ESM loader, so + // resolution has to be Node's — import specifiers keep their `.js` + // extension, as they already did in the JS sources. + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"], + "strict": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "outDir": "./dist", + "sourceMap": true + }, + "include": ["src/**/*.ts", "__tests__/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/loadtest/swarm/vitest.config.mts b/src/loadtest/swarm/vitest.config.mts new file mode 100644 index 000000000..fd8e066f2 --- /dev/null +++ b/src/loadtest/swarm/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// `__tests__/*.spec.ts`: unit tests of the pure parts, and one integration test +// running a swarm against an in-process y-websocket server (`_server.ts`). +export default defineConfig({ + test: { + include: ['__tests__/**/*.spec.ts'], + testTimeout: 30000, + clearMocks: true, + }, +}); diff --git a/src/loadtest/swarm/yarn.lock b/src/loadtest/swarm/yarn.lock new file mode 100644 index 000000000..e73fc49a8 --- /dev/null +++ b/src/loadtest/swarm/yarn.lock @@ -0,0 +1,1349 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@esbuild/aix-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc" + integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ== + +"@esbuild/android-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504" + integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A== + +"@esbuild/android-arm@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381" + integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg== + +"@esbuild/android-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801" + integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q== + +"@esbuild/darwin-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc" + integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw== + +"@esbuild/darwin-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de" + integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw== + +"@esbuild/freebsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e" + integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw== + +"@esbuild/freebsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7" + integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg== + +"@esbuild/linux-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d" + integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug== + +"@esbuild/linux-arm@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753" + integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w== + +"@esbuild/linux-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b" + integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ== + +"@esbuild/linux-loong64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98" + integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ== + +"@esbuild/linux-mips64el@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca" + integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA== + +"@esbuild/linux-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2" + integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ== + +"@esbuild/linux-riscv64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27" + integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA== + +"@esbuild/linux-s390x@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb" + integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg== + +"@esbuild/linux-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485" + integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ== + +"@esbuild/netbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1" + integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw== + +"@esbuild/netbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de" + integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw== + +"@esbuild/openbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef" + integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ== + +"@esbuild/openbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd" + integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw== + +"@esbuild/openharmony-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce" + integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q== + +"@esbuild/sunos-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4" + integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g== + +"@esbuild/win32-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532" + integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ== + +"@esbuild/win32-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7" + integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA== + +"@esbuild/win32-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87" + integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g== + +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": + version "4.10.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6" + integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.2": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + +"@eslint/config-array@^0.23.5": + version "0.23.5" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95" + integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== + dependencies: + "@eslint/object-schema" "^3.0.5" + debug "^4.3.1" + minimatch "^10.2.4" + +"@eslint/config-helpers@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377" + integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw== + dependencies: + "@eslint/core" "^1.2.1" + +"@eslint/core@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce" + integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/js@10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583" + integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== + +"@eslint/object-schema@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091" + integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== + +"@eslint/plugin-kit@^0.7.2": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz#cc7268cc36405b331ef92db1bc37971f21d66fe1" + integrity sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q== + dependencies: + "@eslint/core" "^1.2.1" + levn "^0.4.1" + +"@humanfs/core@^0.19.2": + version "0.19.2" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60" + integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== + dependencies: + "@humanfs/types" "^0.15.0" + +"@humanfs/node@^0.16.6": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed" + integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== + dependencies: + "@humanfs/core" "^0.19.2" + "@humanfs/types" "^0.15.0" + "@humanwhocodes/retry" "^0.4.0" + +"@humanfs/types@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090" + integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@jridgewell/sourcemap-codec@^1.5.5": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz#f4c663e862f06dc98ca4d453862c46902789a18d" + integrity sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw== + +"@napi-rs/lzma-linux-x64-gnu@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz#e57d4306966078662038094fb38eb9146dc3aea9" + integrity sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ== + +"@opentelemetry/api@^1.4.0": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05" + integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q== + +"@rollup/rollup-android-arm-eabi@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz#e1d06062fb451578b3fb0bdea3360daaa1395f44" + integrity sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw== + +"@rollup/rollup-android-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz#2e81dec4e9e879c95c8266e30da67074c4998224" + integrity sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw== + +"@rollup/rollup-darwin-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz#f48f92f44454aed23d013f7cdb6da0dc2cb598ae" + integrity sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA== + +"@rollup/rollup-darwin-x64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz#39b0b2ad27c49b40aec71fb20210e2e3be5fdbd0" + integrity sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw== + +"@rollup/rollup-freebsd-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz#0c85d18f5b9228b7195adbbbc97c1adaf41b8106" + integrity sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw== + +"@rollup/rollup-freebsd-x64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz#0c6c27471ea950213aad76418f075c96f57e9533" + integrity sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw== + +"@rollup/rollup-linux-arm-gnueabihf@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz#28cba40d417fb3d2c1536941b02143a2bdda72be" + integrity sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw== + +"@rollup/rollup-linux-arm-musleabihf@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz#c9f06aa876d6d312012a2048d9e5533c581a7341" + integrity sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA== + +"@rollup/rollup-linux-arm64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz#b26376804ca32b3dcc8f68930f765394fb4e4814" + integrity sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg== + +"@rollup/rollup-linux-arm64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz#65f6f5deee9d30904b4d52ee3d45679055743cd1" + integrity sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ== + +"@rollup/rollup-linux-loong64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz#15d7e44538e967a3d6132f5b9f27f02346df6850" + integrity sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw== + +"@rollup/rollup-linux-loong64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz#7afad2b9e41941344883154f754b7f772ef16b01" + integrity sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA== + +"@rollup/rollup-linux-ppc64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz#3f3131b1a9585851b2875c86a49f6e6deb9e68e0" + integrity sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA== + +"@rollup/rollup-linux-ppc64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz#463aeee26c62983c1fc235708d1b41baf4735f98" + integrity sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA== + +"@rollup/rollup-linux-riscv64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz#cb495bd8cc978e17593e0d323b36fd95b5eab098" + integrity sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag== + +"@rollup/rollup-linux-riscv64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz#655b69144b1e786e0bf8ac3b727f0d7fee590a08" + integrity sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig== + +"@rollup/rollup-linux-s390x-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz#70b175b0361a7898acc294a6b14001e2c3ad99be" + integrity sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g== + +"@rollup/rollup-linux-x64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz#0c68fa212df249f996b2d75e8fbb3766b1582ee8" + integrity sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw== + +"@rollup/rollup-linux-x64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz#93af6206edf751469a43c3614164360136540838" + integrity sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w== + +"@rollup/rollup-openbsd-x64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz#7c7680c4004fd602b59cd19f3294fa1b89d0c683" + integrity sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png== + +"@rollup/rollup-openharmony-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz#f389ff4d8ee8a635ff51d76e8ff20b7db2292bce" + integrity sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ== + +"@rollup/rollup-win32-arm64-msvc@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz#55a286f40f9d03c653ceb7183d6c6179ec312c75" + integrity sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A== + +"@rollup/rollup-win32-ia32-msvc@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz#7eb577b64e2a39115182a87a2ea957608d8331f6" + integrity sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ== + +"@rollup/rollup-win32-x64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz#0e7a7cd5f0db2f570fc312687d67baf21283a3f6" + integrity sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ== + +"@rollup/rollup-win32-x64-msvc@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz#f8e11d8ac827db7695d50f1def4c00a7f3cd1510" + integrity sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ== + +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + +"@types/estree@1.0.9", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/node@*": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.6.2.tgz#11bfb8e00bafe728d2113e474a7637becc9ab279" + integrity sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g== + dependencies: + undici-types "~8.9.0" + +"@types/node@24.13.3": + version "24.13.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.13.3.tgz#49f18bd3c647866dcda51a0756c145e14590ce16" + integrity sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q== + dependencies: + undici-types "~7.18.0" + +"@types/ws@8.18.1": + version "8.18.1" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" + integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== + dependencies: + "@types/node" "*" + +"@typescript-eslint/eslint-plugin@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz#bf74cc392ebcaaf096bc8b4c4d7bbeb0677687b8" + integrity sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.69.0" + "@typescript-eslint/type-utils" "8.69.0" + "@typescript-eslint/utils" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + ignore "^7.0.5" + natural-compare "^1.4.0" + ts-api-utils "^2.5.0" + +"@typescript-eslint/parser@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.69.0.tgz#de3ead2b35e5c71580eda40820adb4fd14834ca1" + integrity sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw== + dependencies: + "@typescript-eslint/scope-manager" "8.69.0" + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.69.0.tgz#cf728554436a50e644a5214a89fe02cb1ffa9af8" + integrity sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.69.0" + "@typescript-eslint/types" "^8.69.0" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz#13f3d1e25108e95a9ceb5a198806d1fa558f8c7a" + integrity sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ== + dependencies: + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + +"@typescript-eslint/tsconfig-utils@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz#d3b0ccc781ab252a90a0b3989b9d1eb85ab59469" + integrity sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ== + +"@typescript-eslint/tsconfig-utils@^8.69.0": + version "8.70.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz#e9353a6e5f3498a29b40946ca3e11f6166fef09e" + integrity sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg== + +"@typescript-eslint/type-utils@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz#7ce68d2ebcbedd8421806c27a7f360755017159f" + integrity sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA== + dependencies: + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + "@typescript-eslint/utils" "8.69.0" + debug "^4.4.3" + ts-api-utils "^2.5.0" + +"@typescript-eslint/types@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.69.0.tgz#5d9ad3f707c2e4f70a2db540031104df3e63bcf5" + integrity sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA== + +"@typescript-eslint/types@^8.69.0": + version "8.70.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.70.1.tgz#2022b9d07d057174eba25b9bbd289641d262a196" + integrity sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q== + +"@typescript-eslint/typescript-estree@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz#efa915913ffe2049bbfd26092b95d1bc7c9c454f" + integrity sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w== + dependencies: + "@typescript-eslint/project-service" "8.69.0" + "@typescript-eslint/tsconfig-utils" "8.69.0" + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + debug "^4.4.3" + minimatch "^10.2.2" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.5.0" + +"@typescript-eslint/utils@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.69.0.tgz#67ad9c00edf12fe2fbc0bf0a71b00822a8d02e97" + integrity sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.69.0" + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + +"@typescript-eslint/visitor-keys@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz#f659785dbb79733c40499f71a65439e2033966b5" + integrity sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w== + dependencies: + "@typescript-eslint/types" "8.69.0" + eslint-visitor-keys "^5.0.0" + +"@vitest/expect@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.7.tgz#70a34158383d008c3bf5d802e2643317f09df6d8" + integrity sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.7" + "@vitest/utils" "3.2.7" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.7.tgz#331be944cb783c642dd42bd743411aca24ea0466" + integrity sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA== + dependencies: + "@vitest/spy" "3.2.7" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.7", "@vitest/pretty-format@^3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.7.tgz#2a7b593f8e007e9d8ef7e7343aa30ec73fdeaf29" + integrity sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/runner@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.7.tgz#c0c080228189f1fa6cda40f59be09d746b0aca51" + integrity sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA== + dependencies: + "@vitest/utils" "3.2.7" + pathe "^2.0.3" + strip-literal "^3.0.0" + +"@vitest/snapshot@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.7.tgz#a3a7e1950ce99ec4cf02395e20ddca403b6c818e" + integrity sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g== + dependencies: + "@vitest/pretty-format" "3.2.7" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.7.tgz#ca7fbee44019523ca450395d9a2284ce9ece1f31" + integrity sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.7.tgz#302c8126211ac4dfea87b3b5085c098d6d22e89e" + integrity sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw== + dependencies: + "@vitest/pretty-format" "3.2.7" + loupe "^3.1.4" + tinyrainbow "^2.0.0" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.16.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940" + integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ== + +ajv@^6.14.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492" + integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + +bintrees@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8" + integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw== + +brace-expansion@^5.0.8: + version "5.0.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec" + integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ== + dependencies: + balanced-match "^4.0.2" + +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + +check-error@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.3.tgz#2427361117b70cca8dc89680ead32b157019caf5" + integrity sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA== + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@^4.3.1, debug@^4.3.2, debug@^4.4.1, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + +"esbuild@^0.27.0 || ^0.28.0": + version "0.28.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" + integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.2" + "@esbuild/android-arm" "0.28.2" + "@esbuild/android-arm64" "0.28.2" + "@esbuild/android-x64" "0.28.2" + "@esbuild/darwin-arm64" "0.28.2" + "@esbuild/darwin-x64" "0.28.2" + "@esbuild/freebsd-arm64" "0.28.2" + "@esbuild/freebsd-x64" "0.28.2" + "@esbuild/linux-arm" "0.28.2" + "@esbuild/linux-arm64" "0.28.2" + "@esbuild/linux-ia32" "0.28.2" + "@esbuild/linux-loong64" "0.28.2" + "@esbuild/linux-mips64el" "0.28.2" + "@esbuild/linux-ppc64" "0.28.2" + "@esbuild/linux-riscv64" "0.28.2" + "@esbuild/linux-s390x" "0.28.2" + "@esbuild/linux-x64" "0.28.2" + "@esbuild/netbsd-arm64" "0.28.2" + "@esbuild/netbsd-x64" "0.28.2" + "@esbuild/openbsd-arm64" "0.28.2" + "@esbuild/openbsd-x64" "0.28.2" + "@esbuild/openharmony-arm64" "0.28.2" + "@esbuild/sunos-x64" "0.28.2" + "@esbuild/win32-arm64" "0.28.2" + "@esbuild/win32-ia32" "0.28.2" + "@esbuild/win32-x64" "0.28.2" + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-config-prettier@10.1.8: + version "10.1.8" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97" + integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w== + +eslint-scope@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802" + integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== + dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + +eslint@10.9.0: + version "10.9.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.9.0.tgz#3d86068a06c6c78161a4062e69874d38f59d498b" + integrity sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.2" + "@eslint/config-array" "^0.23.5" + "@eslint/config-helpers" "^0.7.0" + "@eslint/core" "^1.2.1" + "@eslint/plugin-kit" "^0.7.2" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.14.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^9.1.2" + eslint-visitor-keys "^5.0.1" + espree "^11.2.0" + esquery "^1.7.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + minimatch "^10.2.5" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" + integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== + dependencies: + acorn "^8.16.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^5.0.1" + +esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +expect-type@^1.2.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.4.4" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6" + integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@^7.0.5: + version "7.0.9" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.9.tgz#475b2197ade916edab05ade35c691518e8543382" + integrity sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw== + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-glob@^4.0.0, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +isomorphic.js@^0.2.4: + version "0.2.5" + resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz#13eecf36f2dba53e85d355e11bf9d4208c6f7f88" + integrity sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw== + +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lib0@0.2.114: + version "0.2.114" + resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.114.tgz#0b0e55c3ffa8768fe3d9efca971059f465db4baf" + integrity sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ== + dependencies: + isomorphic.js "^0.2.4" + +lib0@^0.2.102, lib0@^0.2.85, lib0@^0.2.99: + version "0.2.117" + resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.117.tgz#6c3f926475d28904af05b590703cbbbc29475716" + integrity sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw== + dependencies: + isomorphic.js "^0.2.4" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== + +magic-string@^0.30.17: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5: + version "10.2.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef" + integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A== + dependencies: + brace-expansion "^5.0.8" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.18: + version "3.3.19" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.19.tgz#336d4aa4bcd4fb24d2cddede7ffeae40bec03f0a" + integrity sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== + +postcss@^8.5.6: + version "8.5.28" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9" + integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== + dependencies: + nanoid "^3.3.18" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prom-client@15.1.3: + version "15.1.3" + resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-15.1.3.tgz#69fa8de93a88bc9783173db5f758dc1c69fa8fc2" + integrity sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g== + dependencies: + "@opentelemetry/api" "^1.4.0" + tdigest "^0.1.1" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +rollup@^4.43.0: + version "4.63.4" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.63.4.tgz#cff29b8d6d959aee7cb5e44b0dad70074493dbf8" + integrity sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ== + dependencies: + "@types/estree" "1.0.9" + optionalDependencies: + "@napi-rs/lzma-linux-x64-gnu" "1.5.1" + "@rollup/rollup-android-arm-eabi" "4.63.4" + "@rollup/rollup-android-arm64" "4.63.4" + "@rollup/rollup-darwin-arm64" "4.63.4" + "@rollup/rollup-darwin-x64" "4.63.4" + "@rollup/rollup-freebsd-arm64" "4.63.4" + "@rollup/rollup-freebsd-x64" "4.63.4" + "@rollup/rollup-linux-arm-gnueabihf" "4.63.4" + "@rollup/rollup-linux-arm-musleabihf" "4.63.4" + "@rollup/rollup-linux-arm64-gnu" "4.63.4" + "@rollup/rollup-linux-arm64-musl" "4.63.4" + "@rollup/rollup-linux-loong64-gnu" "4.63.4" + "@rollup/rollup-linux-loong64-musl" "4.63.4" + "@rollup/rollup-linux-ppc64-gnu" "4.63.4" + "@rollup/rollup-linux-ppc64-musl" "4.63.4" + "@rollup/rollup-linux-riscv64-gnu" "4.63.4" + "@rollup/rollup-linux-riscv64-musl" "4.63.4" + "@rollup/rollup-linux-s390x-gnu" "4.63.4" + "@rollup/rollup-linux-x64-gnu" "4.63.4" + "@rollup/rollup-linux-x64-musl" "4.63.4" + "@rollup/rollup-openbsd-x64" "4.63.4" + "@rollup/rollup-openharmony-arm64" "4.63.4" + "@rollup/rollup-win32-arm64-msvc" "4.63.4" + "@rollup/rollup-win32-ia32-msvc" "4.63.4" + "@rollup/rollup-win32-x64-gnu" "4.63.4" + "@rollup/rollup-win32-x64-msvc" "4.63.4" + fsevents "~2.3.2" + +semver@^7.7.3: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.9.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + +strip-literal@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" + integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== + dependencies: + js-tokens "^9.0.1" + +tdigest@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.3.tgz#831a1fd531f207127ab75d054d4c6c6dedab3ac7" + integrity sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw== + dependencies: + bintrees "1.0.2" + +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + +tinyglobby@^0.2.14, tinyglobby@^0.2.15: + version "0.2.17" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +tinypool@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.6" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.6.tgz#68e278e20287f37beb5f4b0a4db211d280400562" + integrity sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg== + +ts-api-utils@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" + integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +typescript-eslint@8.69.0: + version "8.69.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.69.0.tgz#28a83f296d9c14001ea0691750c90cac8e94ba96" + integrity sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA== + dependencies: + "@typescript-eslint/eslint-plugin" "8.69.0" + "@typescript-eslint/parser" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + "@typescript-eslint/utils" "8.69.0" + +typescript@6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== + +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== + +undici-types@~8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.9.0.tgz#e240d97c8b5d85e5347ce73d25865c7906c1ec9f" + integrity sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +vite-node@3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" + integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== + 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" + +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": + version "7.3.6" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.6.tgz#0547a395e68d3746e9a505f1fd4469fe09b49cc4" + integrity sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg== + dependencies: + esbuild "^0.27.0 || ^0.28.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + +vitest@3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.7.tgz#1944b6ed013a25fd26a73d18e1af92c10a57af6c" + integrity sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.7" + "@vitest/mocker" "3.2.7" + "@vitest/pretty-format" "^3.2.7" + "@vitest/runner" "3.2.7" + "@vitest/snapshot" "3.2.7" + "@vitest/spy" "3.2.7" + "@vitest/utils" "3.2.7" + 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" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +ws@8.18.3: + version "8.18.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== + +y-protocols@1.0.7, y-protocols@^1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.7.tgz#6631c492e75b78b3a61353a60067e6f8a4c38d5f" + integrity sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw== + dependencies: + lib0 "^0.2.85" + +y-websocket@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/y-websocket/-/y-websocket-3.1.0.tgz#c6f6786ca1c0170b9a20f13c9cfc2cdb5c27d683" + integrity sha512-ZNzwH84Ysxv7zjpFNZHjTJvrBZgcAqMljTe+6zrWciAML9LQ18aVylyPNH9faxCXqEOV8I0JY4TGtrIHFX+Xwg== + dependencies: + lib0 "^0.2.102" + y-protocols "^1.0.5" + +yjs@13.6.32: + version "13.6.32" + resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.32.tgz#859dca48d7b2665c6f4b7a4481e97b72ab29e7b2" + integrity sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ== + dependencies: + lib0 "^0.2.99" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==